Build tables for the parts of a config that are a list, not a setting - #6
Open
Ericran wants to merge 2 commits into
Open
Build tables for the parts of a config that are a list, not a setting#6Ericran wants to merge 2 commits into
Ericran wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
ConfigDoc.getRawresolves 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'sGame.iniand Dragonwilds'KnownPlayerListboth work this way.A JSON array of records. Enshrouded's
userGroupsholds 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
Groupmay carry an optionaltable: TableSpecinstead of (or as well as)fields.TableSpecis a union of two row sources, and the distinction is not cosmetic — it's exactly what decides whether a table can be edited.struct-rowsConfigDoc.getAllRaw(), each parsed as an Unreal struct literalarray-rowspath.<i>.<column>array-rowscells bind to ordinary field models, so they inherit the codec, the JSON type coercion (levelstays a number,bypassesPlayerLimitstays a boolean), the write-error reporting and the dirty flag for free, and neitherConfigTablenorConfigEditorhas to know a table is involved.Neither kind adds or removes rows.
json.tsalready 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.iniops.jsonwhitelist.jsonallowlist.jsonpermissions.jsonenshrouded_server.jsonDedicatedServer.inibIsBannedas a tickThe 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, sohideWhenEmptydrops them rather than printing "no entries" four times.Item stack sizesis the nested case: the parser splits the top level only, so the inner literal shows as written.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.Both are from
@gameap/debugwith fixture configs, not a live server.Design notes
A table hangs off
Group, not offFType. AFieldDefmaps 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.hideWhenEmptydrops 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 stockGame.iniwould 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.
inferGroupsexcludes 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 afutureFieldMojang hasn't added yet.path: ''is the document root.ops.jsonand 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.noteoverrides 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*, notn-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
ConfigOverrideSupplyCrateItems,ConfigOverrideItemCraftingCosts,ConfigAddNPCSpawnEntriesContainer. Columns would be a worse view of those than the raw line the Advanced group already gives.LevelExperienceRampOverrides,PerLevelStatsMultiplier_*. One struct with a hundred members is not a hundred rows.ConfigOverrideItemMaxQuantityis 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.spigot.yml'sworld-settings.<world>,bukkit.yml'sworlds.<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.<resource>/<module>elements are genuinely a table, butelementXmlFormatdoesn't index attribute-only elements at all — that's format work, not a schema change.tags/admins, Arma'sadmins[]/motd[]— are lists of strings, not records, and would need a one-column mode plus (for Factorio) leaving leaf mode.Commits
Two, reviewable separately:
TableSpec,getAllRaw(), the Unreal struct parser,ConfigTable.vue, and the Enshrouded/Dragonwilds tables.hideWhenEmpty, root paths,TableColumn.options,TableSpec.note).