Skip to content

Build tables for the parts of a config that are a list, not a setting - #6

Open
Ericran wants to merge 2 commits into
psinetreject:mainfrom
Ericran:config-tables
Open

Build tables for the parts of a config that are a list, not a setting#6
Ericran wants to merge 2 commits into
psinetreject:mainfrom
Ericran:config-tables

Conversation

@Ericran

@Ericran Ericran commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Tables for the parts of a config that are a list, not a setting

The problem

Two shapes in these files don't fit the form's one-address-one-value contract, and in both cases they're the part people most want to look at.

A repeated INI key. Unreal writes a list as the same key once per element, each value a struct literal:

HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Wood_C",Multiplier=2.0)
HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Stone_C",Multiplier=1.5)
HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Fibers_C",Multiplier=3.0)

ConfigDoc.getRaw resolves a repeated key to its last occurrence — right for a scalar someone wrote twice, useless for a list. So on that file the form showed exactly one field, holding (ClassName="PrimalItemResource_Fibers_C",Multiplier=3.0) as an opaque string, and the other two lines were invisible. ARK's Game.ini and Dragonwilds' KnownPlayerList both work this way.

A JSON array of records. Enshrouded's userGroups holds one object per role — the passwords and permission flags — and the generic editor rendered it as one titled group per index. Minecraft's and Bedrock's player lists are worse, because the file is the array: a server with thirty operators got thirty headings called [0], [1], [2], each with the same four fields, and comparing two players meant scrolling between them.

What this adds

A Group may carry an optional table: TableSpec instead of (or as well as) fields. TableSpec is a union of two row sources, and the distinction is not cosmetic — it's exactly what decides whether a table can be edited.

kind rows come from editable why
struct-rows every occurrence of one address, via the new ConfigDoc.getAllRaw(), each parsed as an Unreal struct literal no a cell isn't separately addressable — the whole struct is one value
array-rows the elements of a JSON array, addressed path.<i>.<column> yes each cell is a real address the format already reads and writes

array-rows cells bind to ordinary field models, so they inherit the codec, the JSON type coercion (level stays a number, bypassesPlayerLimit stays a boolean), the write-error reporting and the dirty flag for free, and neither ConfigTable nor ConfigEditor has to know a table is involved.

Neither kind adds or removes rows. json.ts already refuses to write through a missing index, so the form can't grow a list; adding a user group or opping a player stays a job for the plain editor or the game.

Where it lands

14 tables across 5 games and 7 files:

game file now before
ARK: Survival Evolved Game.ini 8 read-only tables — harvest amounts, item stack sizes, auto-unlocked engrams, engram overrides, and the four per-species dino damage/resistance lists one field per key, holding the last line of the list
Minecraft ops.json Operators — player, UUID, level, bypass flag one titled group per operator
Minecraft whitelist.json Whitelisted players one titled group per player
Minecraft: Bedrock allowlist.json Allowed players — gamertag, XUID, ignores-limit one titled group per player
Minecraft: Bedrock permissions.json Player permissions — XUID plus a select over visitor/member/operator one titled group per XUID
Enshrouded enshrouded_server.json User Groups — one row per role, permission flags as toggles one titled group per role
RuneScape: Dragonwilds DedicatedServer.ini Players — the roster the server writes, bIsBanned as a tick one field holding the last player

The ARK and player-list halves are the second commit; they're the reason the mechanism is worth having rather than a two-game special case.

What it looks like

ARK's Game.ini. Four of the eight override lists — the other four aren't in this file, so hideWhenEmpty drops them rather than printing "no entries" four times. Item stack sizes is the nested case: the parser splits the top level only, so the inner literal shows as written.

download

Minecraft's ops.json. One row per operator, editable in place, with the file's JSON types preserved — before this it was one titled group per player.

download

Both are from @gameap/debug with fixture configs, not a live server.

Design notes

A table hangs off Group, not off FType. A FieldDef maps one address to one scalar model. Table rows are neither — many values, each a record — so modelling them as a field would have meant bending the form contract for one shape. A group can simply carry a table instead of fields, and every existing code path is untouched.

hideWhenEmpty drops a table-only group that has no rows, instead of showing its empty note. The asymmetry with fields is deliberate: a curated field is worth rendering empty because an empty input is something you can fill in, but no table kind adds rows, so an empty table is a dead section. ARK is what forces it — eight override lists of which a given server uses none or two, and a stock Game.ini would otherwise sprout eight "no entries" headings. The player lists deliberately don't set it: an empty whitelist is a fact worth stating, and the note says how to add to it.

Coverage is per cell, not per subtree. inferGroups excludes the addresses a table actually renders, so a key inside a row that no column names still falls through to a generic group. That matters most for a root-path table, whose path spans the entire file — excluding the subtree there would hide everything. There's a test for a futureField Mojang hasn't added yet.

path: '' is the document root. ops.json and friends have no key to hang a dotted path off; their addresses start straight at the row index (0.name), so the empty path has to mean "no prefix at all" rather than one empty segment.

note overrides the footer text. Worth calling out because the first version of this got it wrong: the struct-rows footer was Dragonwilds' wording, which would have told an ARK admin to unban people from an in-game Server Management screen ARK has no such thing as. The default is now generic, and Dragonwilds carries its own text — its list is read-only for a stronger reason, since the server rewrites the whole file on shutdown.

The struct parser is tolerant and fails visibly. The line format isn't documented anywhere public; it's inferred from Unreal's conventions. So quotes are optional, field order is irrelevant, unknown fields are kept, and a line that doesn't parse is printed verbatim under the table rather than dropped — an unexpected format looks like unexpected text instead of a missing player. It also accepts a subscripted member (ExperiencePointsForLevel[0]=10), because Unreal writes fixed-size array members that way and rejecting them failed the whole row.

Markup is a plain table styled by .gce-table*, not n-table. The panel's Tailwind build doesn't scan plugin sources, and this needs no behaviour a naive-ui component would bring. Colours are the panel's --gameap-* tokens, so it follows light/dark with no variants here.

Deliberately not covered

  • ARK's nested list-of-struct keysConfigOverrideSupplyCrateItems, ConfigOverrideItemCraftingCosts, ConfigAddNPCSpawnEntriesContainer. Columns would be a worse view of those than the raw line the Advanced group already gives.
  • ARK's subscripted single structsLevelExperienceRampOverrides, PerLevelStatsMultiplier_*. One struct with a hundred members is not a hundred rows.
  • ConfigOverrideItemMaxQuantity is only half decomposed. It nests a second struct (Quantity=(MaxItemQuantity=200,bIgnoreMultiplier=true)); the parser splits the top level only, so that cell holds the inner literal verbatim. Short enough to read, and honest about what the file says.
  • Per-world YAML settings (spigot.yml's world-settings.<world>, bukkit.yml's worlds.<world>) are table-shaped but keyed by name rather than index, so they'd need a third row kind. Today's per-world section grouping is reasonable rather than broken, so I left it alone rather than widen the union speculatively.
  • MTA's <resource> / <module> elements are genuinely a table, but elementXmlFormat doesn't index attribute-only elements at all — that's format work, not a schema change.
  • Scalar arrays — Factorio's tags/admins, Arma's admins[]/motd[] — are lists of strings, not records, and would need a one-column mode plus (for Factorio) leaving leaf mode.

Commits

Two, reviewable separately:

  1. Add table groups for repeated data, and use them for two gamesTableSpec, getAllRaw(), the Unreal struct parser, ConfigTable.vue, and the Enshrouded/Dragonwilds tables.
  2. Use the table groups for ARK and the four player lists — the schemas, plus the four mechanism extensions each of those files forced (hideWhenEmpty, root paths, TableColumn.options, TableSpec.note).

Ericran and others added 2 commits September 6, 2026 21:41
Two config shapes don't fit the form's one-address-one-value contract, and both
are the part of their file people most want to see:

- Dragonwilds' KnownPlayerList is a repeated INI key, one Unreal struct literal
  per player. getRaw resolves a repeated key to its LAST occurrence - right for
  a scalar that appears twice, useless for a list - so the roster was invisible
  bar one row.
- Enshrouded's userGroups is a JSON array of role objects holding the passwords
  and permission flags, which renders today as one generic group per index.

Adds an optional `table` to Group. A field maps one address to one scalar
model, and table rows are neither, so bending FieldDef to fit would have meant
changing the form contract for one shape; a group can simply carry a table
instead of fields, and every existing path is untouched.

TableSpec is a union of the two row sources, and the distinction is not
cosmetic - it is exactly what decides whether a table can be edited:

- `struct-rows` reads every occurrence of one address via the new
  ConfigDoc.getAllRaw(), parsing each with a small Unreal struct-literal
  parser. No cell is separately addressable, so these are READ-ONLY.
- `array-rows` addresses each cell as `path.<i>.<column>`, which the
  array-expanding JSON format already reads and writes. Cells therefore bind to
  ordinary field models and inherit the codec, the JSON type coercion, the
  write-error reporting and the dirty flag with no new write path at all.

Neither mode adds or removes rows: json.ts will not write through a missing
index, and for Dragonwilds the server would overwrite an invented row anyway.

The Dragonwilds table is read-only for a second reason beyond addressability -
the server owns that list and rewrites the whole file on shutdown, which is why
the entry sets stopWarning. That is the same call this plugin already makes for
Minecraft's ops.json and whitelist.json.

One caveat, stated plainly: the KnownPlayerList line format is not documented
anywhere public. It is inferred from Unreal's conventions, so the parser is
deliberately tolerant (quotes optional, field order irrelevant, unknown fields
kept) and fails VISIBLY - a line it does not recognise is printed verbatim
under the table rather than dropped, so a wrong guess looks like unexpected
text instead of a missing player.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two shapes added with TableSpec were derived from Enshrouded and
Dragonwilds, but they describe files upstream already ships. Applying them
where they fit turns out to fix the same defect twice more.

ARK's Game.ini (struct-rows, no new mechanism):

  HarvestResourceItemAmountClassMultipliers=(ClassName="..._Wood_C",Multiplier=2.0)
  HarvestResourceItemAmountClassMultipliers=(ClassName="..._Stone_C",Multiplier=1.5)

Same repeated-key shape as KnownPlayerList, so getRaw resolved it to the last
line and the rest of the list was invisible - that one showing as an opaque
struct string. Eight lists now render as read-only tables: harvest amounts,
stack sizes, auto-unlocked engrams, engram overrides and the four per-species
dino damage/resistance lists. Not the ones whose payload is itself a list of
structs (ConfigOverrideSupplyCrateItems and friends), where columns would be a
worse view than the raw line, nor the subscripted single structs
(LevelExperienceRampOverrides), which are one struct with a hundred members.

Minecraft ops.json / whitelist.json and Bedrock allowlist.json /
permissions.json (array-rows over the document root): each file IS the array,
so its addresses start at the row index and there is no key to hang a path off.
The empty path now means the root rather than one empty segment. They rendered
as one titled group per player before - thirty operators, thirty headings.

Four things the mechanism needed to fit them:

- hideWhenEmpty drops a table-only group with no rows instead of showing its
  empty note. A curated field is worth rendering empty because an empty input
  can be filled in; no table kind adds rows, so an empty table is a dead
  section - and a stock Game.ini has none of these eight keys.
- inferGroups now excludes the cells a table actually renders, not the whole
  subtree under its path. A key no column names stays visible, which matters
  most for a root path: it spans the entire file.
- TableColumn takes `options`, so Bedrock's visitor/member/operator is a select
  rather than free text the server would refuse to boot on.
- TableSpec takes `note`. The struct-rows footer was Dragonwilds' wording, which
  told an ARK admin to unban people from a Server Management screen ARK has no
  such thing as; the default is now generic and Dragonwilds carries its own.

Also widens the struct parser to accept a subscripted member
(ExperiencePointsForLevel[0]=10). Unreal writes fixed-size array members that
way, and rejecting them failed the whole row - reporting a valid line as
unrecognised.

351 tests (was 318), typecheck and bundle-shape guard clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant