INI Field Parse System
How Generals Remastered turns INI blocks into live objects with token tables, parse functions, and `offsetof` writes.
Most of the engine's data loading is not hand-written one block at a time. It goes through a small reflection-like system built around FieldParse tables. The format is still plain old INI text, but the runtime parser behaves more like a strict schema loader than a loose config reader.
That strictness is deliberate. Multiplayer depends on identical data load order and identical parsed values across machines. The INI loader in Common/INI/INI.cpp does extra work to preserve that determinism, including a sorted directory walk and a token dispatcher for known top-level block types.
How A Block Gets Parsed
The top of the funnel is INI::loadFileDirectory in INI.cpp:209. Given a base path like Data\INI\Armor, it loads both Armor.ini and any *.ini files in the sibling Armor directory. loadDirectory at INI.cpp:250 sorts the file list and loads root files before subdirectory files so networked machines see the same merge order.
Once a line declares a known block type, findBlockParse in INI.cpp:351 maps the token to a specific handler. The table includes entries like Video, AudioEvent, Object, WaterSet, and many more. A small handler usually reads the block's name, constructs or looks up an instance, and then calls ini->initFromINI with a parse table for that type.
INIVideo.cpp:47 is the simple example. parseVideoDefinition reads the video name, creates a Video, and hands it to ini->initFromINI(&video, TheVideoPlayer->getFieldParse()). The actual field parsing is generic.
How FieldParse Works
The schema lives in Common/INI.h:112. A FieldParse entry is just four pieces of data:
- the text token,
- the parse function,
- optional user data,
- and the byte offset of the destination field.
Water.cpp:41 shows the common pattern. A table entry like {"UScrollPerMS", INI::parseReal, nullptr, offsetof(WaterSetting, m_uScrollPerMs)} says: when the loader sees UScrollPerMS = ..., call parseReal and write the result into that member.
INI::initFromINIMulti in INI.cpp is the workhorse. It reads lines until END, tokenizes the field name, searches one or more parse tables, and calls the parser with the destination pointer computed as (char*)what + offset + extraOffset:
for (int ptIdx = 0; ptIdx < parseTableList.getCount(); ++ptIdx) { int offset = 0; const void* userData = nullptr; INIFieldParseProc parse = findFieldParse( parseTableList.getNthFieldParse(ptIdx), field, offset, userData); if (parse) { try { (*parse)(this, what, (char*)what + offset + parseTableList.getNthExtraOffset(ptIdx), userData); } catch (...) { /* wrap in INIException with line/file context */ } found = true; break; } } if (!found) { DEBUG_CRASH(("Unknown field '%s' in block '%s'", field, m_curBlockStart)); }
That is the whole trick. There is no generated code and no RTTI. Just a token table plus offsetof. If no parse entry matches, the loader treats it as a bug — a crash-level error. A missing END token also throws. This parser is intentionally unforgiving: silent skipping of unknown fields would let typo'd INI values ship without warning, so it faceplants instead.
Why There Is A Multi-Table Variant
Simple data types use one table. Module hierarchies use more than one. MultiIniFieldParse in INI.h:129 lets a subsystem stack parse tables together, and W3DModelDrawModuleData::buildFieldParse in W3DModelDraw.cpp:1269 is the representative pattern: first add the base ModuleData table, then append the model-draw-specific entries.
That is how module inheritance works without a custom parser per derived type. Shared fields stay in the base table. Derived classes bolt on extra tables. The loader still runs one generic loop.
The parse functions do more than string-to-number conversion. Many perform unit conversion on the way in. parseVelocityReal, parseAccelerationReal, parseAngleReal, and the duration parsers in INI.h:278 all translate authoring units into the engine's internal per-frame units. That means the values stored in memory are often not the exact literals written in the file.
Quirks
- The loader is deterministic on purpose. Sorted filenames, root files before subdirectory files, so merges stay stable across machines. Multiplayer desync traced to different load orders is unusual but real — Windows filesystem enumeration order is not guaranteed to be stable, hence the explicit sort.
- Unknown tokens are fatal. This is not a "skip what you don't understand" config system. An unmatched field throws
INIExceptionwith line and file context. This is why typo'd INI fails loud at load time instead of silently disabling a feature six weeks later. userDatais heavily overloaded. Many parsers use it as a lookup table pointer, enum tag, or mini mode switch instead of raw data. A parse function signature likevoid parseBitMask(INI*, void*, void*, const void* userData)withuserDatapointing at aLookupListRec*is the common pattern.offsetcan be zero for custom sub-parsers. Not every entry writes straight into a leaf field — some parse functions mutate the owning object directly. These are rare but legitimate; zero-offset entries usually ignore the pointer they're handed and usewhat(the owning object) instead.- Multi-table parsing is the quiet backbone of module inheritance. Forgetting to append the base table in a derived module's
buildFieldParsedoesn't merge fields for you automatically — the derived module just silently loses all base-class fields. This is a common new-contributor mistake. - Unit conversions happen at parse time.
parseVelocityReal,parseAccelerationReal,parseAngleReal, and duration parsers translate authoring units (per-second, degrees, seconds) into internal per-frame units. The value in memory is not the literal in the file —100 velocity per secondbecomes100 / 30per-frame at 30 Hz. Authors reasoning from runtime values back to INI must reverse the conversion. - Directory sort interacts with
!andz_prefixes. Filenames starting with!sort first, withz_sort last. Mods lean on this to override or post-apply data without editing the originals. Breaking the sort (e.g., case-insensitive comparison on a case-sensitive filesystem) would silently change merge behavior.