Skip to content

Maelstrom data support - #33

Open
ivan-ushakov wants to merge 32 commits into
crossplatformfrom
feature/maelstrom-data-support
Open

Maelstrom data support#33
ivan-ushakov wants to merge 32 commits into
crossplatformfrom
feature/maelstrom-data-support

Conversation

@ivan-ushakov

Copy link
Copy Markdown
Collaborator

No description provided.

@ivan-ushakov
ivan-ushakov force-pushed the feature/maelstrom-data-support branch from bd422fa to ea8767e Compare August 7, 2026 17:10
ivan-ushakov and others added 23 commits August 8, 2026 18:33
…re caches

Maelstrom (KD Lab, 2007) runs on VistaEngine, but not on this VistaEngine: its
content was authored before ~August 2007 and this tree is a 2008+ snapshot. The
gap is dated in our own source -- the "|name|first" / "|type|second" aliases
carry CONVERSION 31.07.07, TextDB and TerToolCtrl carry 2008-1-18 and 2008-1-24
-- and it was enough to abort in loadAllLibraries before a window ever opened.

XPrmIArchive already absorbs most of that drift on its own: unknown field names
are skipped, missing ones keep defaults, unregistered enum values and classes
warn and carry on. What it cannot absorb is a field whose C++ type changed under
the name, because strtol parks on the '.' of a float literal and closeNode then
demands the ';' and aborts.

tools/maelstrom_convert.py rewrites exactly that. The rules were not guessed
from the data -- diffing by field name conflates unrelated structs, and did
report two false positives -- but derived by pairing every ar.serialize(member,
"wire", ...) site with the member's declaration in both trees. Across the
modules this build compiles that resolves ~1500 wire names a side and yields
two real changes, both in Scripts/Engine/RigidBodyPrmLibrary: steering_duration
is float there and int here, and groundPass/waterPass are PassabilityFlags
rather than bool. It works on bytes, since the files are CP1251 with CRLF and
the engine reads them verbatim.

Fonts needed a third, generated file. Maelstrom rasterised its fonts offline and
ships no .ttf at all; by 2008 the engine read a TTF named by UI_FontAttributes,
a different file under a different shape. Without it the default font never
creates and FT::Font::size faults on null inside UI_TextParser.

The engine side is three fallbacks, each reached only when the normal path finds
nothing, so this engine's own data behaves exactly as before:

- Models. Maelstrom ships no .3DX for units or buildings -- 32 in the whole
  distribution, all terrain decals and sky helpers -- only 1896 baked .dat. That
  cache turns out to be the same chunked container as a .3DX written with the
  same C3DX_* ids, and cSkinVertex's layout never changed, so the vertex and
  index bytes go to initBuffersInPlace verbatim. LoadInternal can be reused for
  the chains block, nodes, materials and lights; LoadChainData cannot, because
  baking flattened the animation chunks and C3DX_ANIMATION_GROUP holds a plain
  record where a .3DX nests sub-chunks under the same id.
- Textures. Shipped the same way, and simpler: ordinary DDS under the detail
  level the engine already selects. Without this everything renders white.
- Terrain colour. loadVMP handled S5L2 already, but that revision stores one
  palette index per cell where clrBuf holds RGB565, and the existing conversion
  needs a true-colour buffer that is not allocated at run time -- so the branch
  fell through a release-mode no-op assert and left the terrain black. Read the
  indices through the world's own inDam.act palette instead.

Camera::DrawSilhouetteObject is guarded on the null device and registered as
Render-PORTING.md #22: stencil work that was never ported, unreached on retail
Perimeter 2 and so unnoticed until data that uses it arrived.

Documents/Maelstrom-PORTING.md is the register -- the drift, the converter, the
cache formats, and what is still approximate. The visibility sets are the part
to know about: Maelstrom's per-LOD group indices address a structure this tree
replaced, so each set is collapsed to one catch-all group and parts the original
hid per animation state are all shown.

Perimeter 2 still loads and exits clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

Maelstrom's interface is drawn from data whose schema predates this tree by a
year, and two groups of fields it still writes are ones we stopped reading.

The in-game HUD chrome is a 3D model, not UI sprites: UI_BackgroundScene draws
one per race through a camera of its own, with the buttons, minimap and text
laid over it as 2D controls. That camera used to be described in the data --
position, focusx, perspective -- and the read had been left commented out. By
2008 the focus had become the per-model "scale" (x scale2focus), the camera had
gone orthographic, and a new modelPosition_ pushed the model 1024 units off the
origin, so Maelstrom's model was drawn with Perimeter 2's camera: too large,
wrong projection, ~130 units too high. The focus is useless without the
placement, and only the translation is new -- modelAngles_ is the same constant
in both engines, and zeroing it makes the model vanish edge-on.

The minimap needed three fields that drifted apart. minimapAngle moved from
Environment to Universe, so an old world writes it in the wrong block;
getAngleFromWorld became exclusive with rotateByCamera and is now unreachable
for data that sets both; and rotationScale did not exist, because rotating the
map always rescaled it to fit. Maelstrom's worlds are 2048x4096 and turn the
minimap 90 degrees, which needs all three.

Perimeter 2 writes none of these fields, so it keeps its constructed defaults
throughout -- measured, its minimap parameters are unchanged.

Documents/Maelstrom-PORTING.md carries both, plus two new open items: the main
menu draws no UI at all off a normal start, and OPTION_SCREEN_SIZE is an index
into a C++ list, so Maelstrom's saved value picks a resolution its data never
meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GameShell::init loads exactly one path, Scripts\Content\Triggers\GlobalTrigger.scr.
Maelstrom's sits a directory up, in Scripts\Content\, and its Triggers\ holds only
AI scripts -- so the chain loaded empty.

That chain is what starts the game. Its START trigger runs a Hide Cursor / Start
Main Menu sequence, and Start Main Menu carries the ActionStartMission that loads
Resource\Worlds\Menu.spg -- Maelstrom's main menu, like Perimeter 2's, is a running
mission. With the file unread nothing fired: no mission, no screen selected, not one
control reaching UI_ControlBase::redraw, and a black window that reads as a renderer
fault and is not one.

All eight classes the chain names still exist here, so it runs as written once found,
and the intro reels skip cleanly under DisableVideo. Handled in the converter rather
than with an engine fallback: it is a file location, not a format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nested

Pre-2008 a control state wrote its show modes flat, one field per
UI_ControlShowModeID name, inside a transparent openBlock(""). By 2008 the same
table had moved under a named "showModes" block. The contents are identical
either way -- EnumTable::serialize is what wrote them then and what writes them
inside the block now -- but our reader asks for the block, openStruct fails, and
it moves on.

Every control in Maelstrom's data therefore loaded with an empty show-mode table,
and a control with no show mode draws no sprite at all. That is what made the
interface look like a texture-loading fault: a blank main menu, white boxes where
the HUD's resource icons belong, a white panel where the minimap belongs. No
texture ever failed to load -- none was ever asked for. Measured before and after
on the same controls: tex=<no-mode> throughout, then real names
(1024_START_SCREEN.DDS, 1024_LOAD_MISSION.TGA, ...).

Perimeter 2 writes the block and takes the existing path unchanged; verified its
menu still resolves its own sprites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fields of UI_ControlBase drifted between Maelstrom's engine and this one,
and with the show modes now read they were what was left of the blank start
screen:

  locText -> text                 a caption was a localization key the engine
                                  resolved at load, not a literal; unread, every
                                  button, title and prompt came up untitled.
  borderColor -> borderClr        a rename *and* a type change, sColor4f to
  borderOutlineColor -> ...Clr    Color4c.  Pointing Color4c::serialize at a
                                  float aborts the load mid-number, so read a
                                  Color4f and convert.
  borderEnabled -> gone           the third flag that gated drawing at all.
  screenZ_ -> screenZ             depth stayed 0 everywhere and each screen drew
                                  in list order rather than in depth order.

The white sheet was the second of these: every screen has a full-screen
"background" control whose only job is a borderFill dimming the world behind it
to 30% black.  The flag kept its name, the colour did not, so the fill drew at
the constructed opaque white.

All four are input-only and ask for the old name only after the new one is
missing -- a name the data does not carry costs openNode a rescan of the whole
control, children and all.  borderEnabled has no such guard, so it is asked for
only where a border is actually switched on.

Verified: Maelstrom reaches its start screen with captions, the dim behind them
and the right draw order; Perimeter 2 loads C2_M08 unchanged, in the same 2s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sun, sky, fog and shadow gradients, the sky models, the latitude and
slant of the sun and the time of day used to be written flat in a world's
"environment" block.  2008 moved all of it under an "environmentTime"
sub-block, so openStruct fails on a pre-2008 world and it lights itself
entirely from constructed defaults: midday where Menu.spg asks for 9:12,
latitude 36 where it asks for 11, one default cloud layer instead of its
own three sky models, and the built-in sun ramp instead of its 8-key one.

The visible half of that is ambient_maximal -- 0.2 against the 0.5 the
world asks for.  Terrain survives it, its colour being baked per fine cell
in the height map, but an object is lit from the sun alone, so every
surface facing away from it comes out near black.  Measured over matching
patches of the same shot against the original game: terrain at 0.42x its
brightness, the shaded side of a tower block at 0.06x.

objectShadowing rides along.  One ShadowingOptions used to light the
ground and the objects standing on it alike; the 2008 split leaves the
objects on a default the world never chose, so give them the ground's.

The global_<name>_color flags beside each gradient are deliberately not
read: the writer resolved them before saving, so the copy in the world is
already the global gradient -- c1_m1.spg sets all six and carries
Scripts\Content\GlobalAttributes' 11-key sun_color verbatim.

Perimeter 2 writes the block and takes the existing path unchanged;
verified that -world C2_M08 loads without entering the conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ources

Drift between Maelstrom's data and this tree comes in two shapes.  Where a
field's type changed under its name, rewriting the value is the whole fix and
tools/maelstrom_convert.py does it offline.  Where a field changed nesting or
owner there is nothing to rewrite and nothing for the archive to skip: the
reader asks for a block the old writer never wrote and the entire subtree goes
unread -- a control's show modes, a world's whole lighting, all of its sources.

Those sites were being detected at runtime: ask for the 2008 name, fall back to
the old one when it is missing.  That works, and for one or two fields it is
tidy, but it does not scale.  A name the data does not carry costs
XPrmIArchive::openNode a rescan of the whole enclosing block -- three passes,
children and all -- so Perimeter 2 pays for every fallback, on every control in
the library.  And the sources below cannot be detected that way at all: they
need a different call graph, not a different field name.

So they are written twice and chosen by MAELSTROM_DATA, off by default:

    cmake -S . -B build-mael -DMAELSTROM_DATA=ON

The rule at each site is #ifdef MAELSTROM_DATA / #else / #endif with the 2008
layout in the #else, so a stock build is the code that was already there -- no
Maelstrom branches, no fallback lookups, and nothing a bug in one can reach.
The cost is that a binary reads one game's data or the other's.  Converted:
the environment's lighting, the minimap's rotation and its three drifted
flags, control show modes, captions, border colours, depth, and the background
scene's camera.

The sources are new here.  A world's SourceZones -- the zones that hold its
standing effects, its damage and its unit generators -- were written in the
environment block and 2008 moved them into Universe's sourceManager.  Unread,
every placed effect in the world is absent: in Maelstrom's menu that is each
building fire, every smoke column and the green outflow from the pipe.  This
looked like a renderer fault for a long time, because the .effect files all
load, the world builds 205 cEffects, and the particle renderer is reached
thousands of times a frame by the sun, the moon and the coast foam -- every
measurement short of which texture reached SetMaterial said the sprites were
being submitted and dropped.  They were never created.

They also force the load order back: camera, universe, environment, where 2008
reads the environment first.  A source refers to units -- its owner, its
targets, the squads a generator fills -- and SourceManager::serialize switches
each one on as it finishes reading it, so the players have to be in place
first.  Maelstrom's own engine read the environment last for this reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One object used to own it: EnvironmentAttributes, written as the world's
"environmentColors" node.  2008 dissolved that object -- its fields became
Environment's own, its fog-of-war colours FogOfWar's, two of its constants
cWater's -- and moved the group out of the world into a preset file.

Maelstrom ships no Scripts\Content\Presets\ at all, so loadPreset() opened
nothing and the whole SERIALIZE_PRESET_DATA branch never ran, on any world.
Menu.spg asks for fog at 900-1200 and a 2-1300 camera frustum and got the
constructed 1000-1400 and 30-4000; the weather, the shore sprites, the lens
flare and the underwater effect were not read at all.

The group sits at two depths, so it takes two things.  Most of it -- fog_enable,
the underwater, bloom and DOF settings, outside, lensFlare_, fallLeaves, the ice
and chaos textures, the cloud shadow -- is written flat in the environment block
where this already reads it, and only needed the branch to run under the world
filter as well.  The rest needs a real descent: openBlock is a no-op in an XPrm
archive, editor-only grouping that does not nest, and openNode's rescan skips a
nested block whole, so from the environment's level not one name inside
environmentColors is visible.  openStruct descends for real.

ownAttributes picks the copy.  11 of the 51 worlds carry their own node; the
other 40 take the global one, which Maelstrom kept in Scripts\Content\
GlobalAttributes -- that file is its preset file, and loadPreset() now reads its
environmentColors for exactly the worlds that ask for it.

timeColors_ is deliberately not read.  It is the node's own copy of the six sky
gradients, and a world is not lit from it: Maelstrom lit from EnvironmentTime's
gradients, written flat in the environment block, and reached into a timeColors_
only for the global set (ReplaceGlobal(GlobalAttributes::instance().
environmentAttributes_.timeColors_)).  What the editor saved beside them in the
world is that global set, a 9-key ramp against Menu.spg's own 8-key one, so
reading it would overwrite a world's own lighting with the global default.

Two names drifted rather than moved: outside was capitalised to Outside, taken
with the archive's own |a|b alias so Perimeter 2 matches on the first name and
pays nothing for the second, and FogOfWar's fogColor/scoutAreaAlpha were
fogOfWarColor/scout_area_alpha.

Verified against Maelstrom's data: fog 900-1200, height_fog_circle 500, frustum
2-1300, hideSmoothly true, effects 0/0/1e6, outside = ENVIRONMENT_WATER, and the
underwater and ice textures resolving to real paths instead of empty strings.
Perimeter 2 compiles the #else throughout and still loads to Universe created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Perimeter 2's content sets is_reflect_sky on nothing, so the cubemap was
registered as having no consumer and skipped.  That is true of Perimeter 2
alone: 116 of Maelstrom's 1031 models name their reflection map sky.*, among
them the city buildings, whose glass material carries a diffuse averaging
RGB(45,42,31).  Nearly all of that glass's colour is the reflection, so without
it the towers render black -- which is what they did.

cRenderSky draws again, from Environment::graphQuant.  All six faces on the
first frame, then one face per frame, and only the sky scene goes into each, so
the steady cost is about one extra sky render a frame -- the amortization the
original was built around.

The faces are not rendered into directly.  Every renderer here builds its own
SDL_GPUColorTargetInfo, so aiming a pass at a single cube layer would mean
threading a layer argument through all ten of them.  Instead a face is drawn
into an ordinary offscreen 2D target -- the path the planar water reflection
already proves -- and copyToCubeFace moves it into the layer afterwards: one
256x256 copy per frame.  createCubeTexture pre-fills all six faces with the sky
fone colour, because a face is only written when a pass opens on it and the
first sky draw of a world records nothing; sampled before then, a face read as
uninitialised memory -- magenta on every reflective surface for the first frames.

The shader is a permutation of the reflection shader we already had, because
that is how the original did it too: one vsSkinReflection/psSkinReflection with
an is_cube flag taken off the bound texture's TEXTURE_CUBEMAP.  REFLECT_CUBE=1
carries the original's world reflection vector, dir + (2*dot(n,dir))*n, and a
TextureCube sample; the renderer picks the permutation the same way the original
picks the flag.

The other half of the fix stands on its own: cObject3dx::Draw required
!mat.is_reflect_sky in BOTH the reflection and the bump branch, so these
materials fell through to the plain lit path even when a cubemap existed.  The
original tests pReflectTexture || is_reflect_sky together, with bump as the
else.  Restored.

Verified against Maelstrom's data: the cube holds real sky (a dumped face
measures teal 56,130,151), the windows material binds it with TEXTURE_CUBEMAP
set, the cube pipeline is selected, and the glass is visible again.  Perimeter 2
builds and runs unchanged -- it has no material that reaches any of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maelstrom's .3DX record carries two boxes. cStaticLogicBound::bound is an
optional collision box whose constructor zeroes it and which almost no model
fills; cStatic3dx::bound_box is the real extent, guarded by
is_inialized_bound_box. This tree merged the two into one boundBox, so the
reader has to choose -- and it chose the logic one, leaving boundBox empty for
nearly every model. The original's cObject3dx::GetBoundBox returns bound_box,
so that is the one the merged member has to hold.

Nothing repaired it downstream: cObject3dx's constructor only recomputes a box
when isBoundBoxInited is false, and these files store that flag true.

The damage surfaced far from the cause. UnitEnvironmentBuilding::setModel does
`scale_ = radius()/max(boundBox.radius2D(), 0.001f)`, where an empty box turns
the floor into a x1000 multiplier: corpses and other decor were built at scale
~10^4 (radius 12.99 gave scale 12986.40). Drawn into the shadow map, whose
caster pipeline clamps depth rather than clipping it, that geometry pinned all
2048x2048 texels to 0.0 -- so every receiver compared as occluded and the whole
terrain rendered at shadow intensity.

Measured before and after with a validated readback of the map: 100% of texels
exactly 0.0 before, min 0.060 / max 1.0 / no zeros after, with the object
casters left enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The D3D9 CreateTexture scanned a freshly loaded texture's alpha and recorded
what it found: all-or-nothing alpha set TEXTURE_ALPHA_TEST, fully opaque
cleared both alpha flags. cObject3dx::Draw reads that back through
isAlphaTest() to pick ALPHA_TEST over ALPHA_NONE. Nothing replaced the scan
when the D3D9 backend went, so every material stayed opaque and cut-out texels
drew as solid colour -- the broken windows of Maelstrom's towers are about a
third of the facade texture at alpha 0, and came out as black panes.

Graded alpha is left alone: a material that wants a real blend gets there
through its opacity map (is_opacity_texture), as the original does.

The DDS loaders also had to stop premultiplying. It looked harmless -- it pairs
with a (ONE, ONE_MINUS_SRC_ALPHA) blend and hides the grey halo around cutout
decals -- but it is destructive: it zeroes the RGB of every fully transparent
texel, and a material drawn ALPHA_NONE never looks at alpha, so it reads that
RGB as colour. Nothing downstream can undo it. loadDDS, the base-cache path
every Maelstrom texture comes through, also premultiplied while leaving
isPremultiplied() reporting false, telling its consumers the opposite of what
it handed them.

Both loaders now flag the texture TEXTURE_ALPHA_TEST optimistically, as
createFileImage does for every other format, so the scan above actually runs on
a DDS -- it is gated on that flag, and without it a DDS never reached it.
Specular and bump maps are excluded: their alpha is power and nothing.

Affects both games, not just Maelstrom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two formulas in EnvironmentTime::SetTime changed under the same field names, so
Maelstrom's worlds are read correctly and then lit by the wrong arithmetic.

Objects took the sun at double strength. Before 2008 one ShadowingOptions lit
the ground and the objects on it alike, and SetTime wrote tilemap_color.a*2 and
tilemap_color.rgb*2 into SetSun, which clamped to 1. Splitting a separate
objectShadowing out in 2008 dropped the factor, because a world can now write
the doubled numbers itself -- Maelstrom's cannot, and every one of them asks for
ambient_factor 0.5, so the shaded side of a building was lit at half what the
original gave it. The terrain is untouched: it never had the factor.

shadow_color means something different in each engine. 2008 divides by the
gradient's own darkest channel, treating the colour as a hue and taking the
depth of the shadow from shadow_intensity alone -- hence the editor caption
asking for "normal grey, about 0.5". Before 2008 the colour was the shadow,
doubled and faded by the sun's height, and Maelstrom's worlds are authored that
way: c1_m1 asks for (0.23,0.27,0.47) at noon, which the 2008 reading normalises
to (0.48,0.54,0.90) -- barely a shadow, and the blue gone with it.
time_shadow_off and speed_shadow_off were constructor constants, never
serialized; shadowDecay, which replaced them, has no counterpart in the file.

Both under MAELSTROM_DATA; the 2008 path is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer built every pipeline CULLMODE_NONE, on the reasoning that the
scene passes flip culling per node type and the device does not track
D3DRS_CULLMODE. Reading the original the other way round: setCamera culls back
faces throughout (CurrentCullMode = D3DCULL_CW) and flips the winding for the
reflection camera, whose mirror matrix reverses every triangle (D3DCULL_CCW);
Camera::DrawObject restores that with RS_CULLMODE -1 and DrawSortObject
inherits it, so in the main scene only DrawObjectSpecial turns culling off.

Drawing double-sided what the original culled shows a model's far wall from the
inside, which is most visible through a hole in the near one.

With SDL's counter-clockwise front face, D3DCULL_CW is CULLMODE_BACK and
D3DCULL_CCW is CULLMODE_FRONT, so the reflection case keeps the FRONT it
already had. The shadow caster stays double-sided: what the light sees is a
separate question from what the camera does, and a one-sided caster changes
which surface writes the depth receivers are compared against.

One divergence needs MAELSTROM_DATA. Before 2008 DrawSortObject saved the cull
mode, forced D3DCULL_NONE around the sorted pass and put it back; 2008 dropped
the call. Maelstrom's transparent materials -- glass, foliage cards -- were
authored against the first of those and expect both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
logicRNDinterval is the *integer* macro -- it expands to logicRNDii(int, int) in
the debug build and logicRnd(int, int) otherwise, and RandomGenerator offers only
int operator()(int, int). Two callers hand it a float range, which truncates on
the way in.

Bird::create scaled every flock model by logicRNDinterval over a Rangef, so a
size range of 0.01..0.04 collapsed to logicRnd(0, 0) == 0: gulls, eagles and fish
were created, animated and attached at scale zero, and drew as nothing. Maelstrom
wrote `logicRndInterval(owner->modelSize())`, which resolved to an
`inline float logicRndInterval(const Rangef&)` overload; 2008 dropped that
overload and rewrote the call site to the int one, taking the truncation with it.

SourceBase::environmentAnalysis had the same slip: a wind sensitivity of 0.9..1.1
became 0..0, so move_by_wind multiplied the wind vector away to nothing. The line
immediately below it already uses the float macro.

Both are invisible in retail Perimeter 2 -- no shipped world contains a
SourceFlock, and nothing there sets move_by_wind -- so this only shows up on
Maelstrom's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cObject3dx::PreDraw attaches a unit carrying ATTRUNKOBJ_SHOW_FLAT_SILHOUETTE to
SCENENODE_FLAT_SILHOUETTE *instead of* SCENENODE_OBJECT, so that list is the only
route those units have into the frame. DrawSilhouetteObject returned as soon as it
saw a null gb_RenderDevice3D, which dropped them from the colour pass altogether
rather than merely dropping their outline.

The shadow attach happens earlier in the same PreDraw, and independently of the
camera's own TestVisible, so the units kept casting shadows while being invisible
-- the signature that pinned this down. Retail Perimeter 2 never fills the list,
which is why it went unnoticed; Maelstrom's menu world fills it with the guard
tower, the legionaries and the warship.

Draw the list plainly, exactly as the child-camera branch below already does. The
stencil outline itself is still unported (Render-PORTING #22): the whole effect is
built out of stencil state the SDL GPU backend does not expose yet, and the body
below it would fault on gb_RenderDevice3D's null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cStatic3dx::prepareMesh guarded BuildMeshes/CreateDebrises on gb_RenderDevice3D,
dating from a point in the port where the SDL backend could not raise model vertex
and index buffers at all. It can now, and gb_RenderDevice3D is null on every
platform since the D3D9 backend was retired, so the guard had quietly become
"never build a mesh" for everything reaching this path.

BuildMeshesLod and cStaticSimply3dx::BuildFromNode allocate through
gb_RenderDevice's Create/Lock{Vertex,Index}Buffer, which cSDLRenderDevice
implements, so the honest test is whether a render device exists at all.

Models that arrive as an in-place cache image are unaffected either way -- both
Perimeter 2's .3dxG and Maelstrom's baked .dat carry their buffers already built
and deliberately bypass prepareMesh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every one of these sites indexes a chain list with an index validated against a
different, longer list, or dereferences a pointer that a release build never
checks. None of it mattered while no animation chain ever started: chainIndex
stayed 0 and the visibility group stayed whatever the constructor set. The moment
a unit animates, the first model whose lights, materials or nodes carry fewer
chains than the model itself walks off the end.

cObject3dxAnimation::SetAnimationGroupChain checks the index against the model's
animationChains_, but lights, materials and nodes each keep their own list.
UpdateMatrix already bounds the node case and skips; the light loop in Update, the
two visibility-track readers and ProcessEffect did not, and the material index is
bounded where it is stored rather than at the six places that read it.

UpdateVisibilityGroup took &groups[index] blind. That is undefined for an
out-of-range index, and for an empty list it quietly hands back null, because an
empty vector's data() is null -- the crash was a null dereference in the light
loop's visibleNodes lookup. SetVisibilityGroup only screens the index through an
xassert, so nothing catches it outside a debug build, and a visibility set that
exists but carries no groups slips past DummyVisibilitySet, which only fires when
there is no set at all. Hand out null on purpose and let both readers treat "no
group" as "not visible".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maelstrom's units loaded, drew and moved, but stood perfectly still while doing
it -- legionaries slid across the ground with their legs frozen. Two separate
pieces of drift, both silent because these files serialize by name: a key that no
longer exists reads as nothing, and a name that changed meaning reads as the wrong
thing. Neither produces a parse error.

The list itself was never read. 2008 rewrote it and gave it a new key,
"animationChainsNew", so Maelstrom's "animationChains" matched nothing and every
unit came up with an empty chain list: nothing to start, so ChainController::quant
returned at finished() and no phase ever advanced.

The ids inside it name chains this tree never asks for. Maelstrom kept the gait in
the chain id -- CHAIN_STAND / WALK / RUN / TURN, plus the walking and running
variants of fire and aim -- where 2008 collapsed each family onto one id and tells
the members apart by MovementState's pose and movement bits instead.
StateBuildingStand and UnitLegionary::setMovementChain both look up
CHAIN_MOVEMENTS, findAnimationChainInterval compares chainID for equality, and
nothing matched. CHAIN_STAND survives in this tree but means something else now
(projectiles and items), and CHAIN_RUN and CHAIN_TURN are gone entirely -- their
old values belong to CHAIN_BUILDING_STAND and CHAIN_PAD_STAND today, so the
pre-2008 ids are given fresh values rather than their original ones, and the
descriptor maps the old names onto those.

CONVERSION 15.02.08 already turns Maelstrom's flat movementState into the split
state/terrainType pair, but it can only recover what was written: the pose and
movement bits were never in the file, because they were the chain id. The fold
puts them back. findAnimationChainInterval's test is a superset one, and
getMovementState always names both a pose and a movement, so the sets are
deliberately generous -- a standing unit still reports whichever gait it would
walk with, so the stand chain has to accept all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The converter rewrites a data root; nothing in this tree creates one, and the
document never said so. Record the two routes -- unzip the distribution, or lay
a symlink farm over a read-only pristine copy -- and show both passes of an
actual run, dry and applied, with the counts they print.

Also say where the .ttf comes from, since Maelstrom ships none: copy one out of
Perimeter 2's own Resource\UI\Fonts. Two things there are silent. --font is
engine-relative with backslashes, not a host path, and build_font_attributes
only interpolates the string -- a wrong path converts cleanly and then faults in
FT::Font::size(this=0x0) at run time, which reads as a font bug and is not one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The silhouette section said the draw list was guarded off. It has not been since
the units started drawing; what is still missing is only the outline. Say what
guarding the whole function off actually cost, because the symptom is worth
recognising -- PreDraw routes those objects to SCENENODE_FLAT_SILHOUETTE instead
of SCENENODE_OBJECT, so the units left the frame while their shadows, attached
earlier in the same call, kept drawing. Render-PORTING.md #22 carried the same
stale claim in its status column.

The animation chains had no section at all, though the drift is the largest found
since the show modes. Record both halves -- the renamed list key and the gait
moving out of the chain id -- and the three parts of the fold that are not
mechanical.

Two items in "Still open" were wrong rather than stale. The frame interval really
is absent from the cache, but nothing consumes it: intervalSize() is its only
reader and has no callers, so it is a Models trap, not an open problem. The
basement is not silently ignored on the path that matters -- the cache reader has
to parse it to stay in step, and then drops it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A model's meshes are split into visibility groups and the object shows one at a
time -- a transformer's robo/transform/tank forms, a building's build stages, a
wreck's debris. The unit names the one it wants, and the name is resolved against
the model's group list.

Only the set's head record was being read, so that list was always
DummyVisibilityGroup()'s single catch-all and every name in the data fell back to
group 0. Of the 3436 VisibilityGroup requests in the shipped data, 3294 name a
group that exists in some cache.

The groups are in the cache in full: C3DX_AVS_ONE_RAW carries each one, and
C3DX_AVS_ONE_LODS follows with three index lists into them, one per LOD. Better,
StaticVisibilityGroup::Load already reads Maelstrom's record byte for byte -- it
kept the throwaway lod field through the rewrite. The reader survived; nothing
was calling it here.

Take the first LOD's list. The other two name the same groups in the same order
in all 836 sets that split them, and each LOD renumbers its visible_shift from
bit 0, so LOD 0 matches the bunch masks of every LOD. The node flags come from
the file too -- they cannot be derived from the mesh names the old code used,
because temp_visible_object is empty in all 4235 cached groups; baking had
already resolved it into visible_nodes.

This did not show the wrong parts, it showed too few. isVisible tests the bunch
mask against the group's visibility, and the dummy's is 1 << 0, so only bunches
carrying bit 0 ever passed: 2151 of 3475 LOD-0 bunches, leaving 1324 in 491
models undrawable in any state. An object that never switches groups renders
identically either way, which is why it went unnoticed -- the fault is invisible
until a unit transforms, finishes building or dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RigidBodyPrm's wire list is 23 names longer on the old side, and the missing
names read like the whole pre-2008 flight model: flying_stiffness, rudder_speed,
steering_acceleration_max, the lot. Nineteen of the twenty-three are dead in
Maelstrom too -- declared, defaulted, saved, loaded, never read -- so none of
them can explain any behaviour. Only four are live, and they are named here so
the next person starts from those.

hoverMode and alwaysMoving are the mirror case: fields we read that the old
schema cannot write, tempting to set for aircraft, and already correct at their
defaults. hoverMode means riding on top of the unit beneath, not hovering in
place, and the original has neither that machinery nor canRotate_/canMoveBack_
-- so false reproduces it and setting them would diverge.

Missile knockback is unreachable twice over: absent from the original engine,
and never enabled by the data.

The method note is the part worth keeping. A wire name the old data writes and
this tree does not read proves nothing on its own; check that the original
consumed it before calling it a regression.

Also record what the menu actually is -- a scripted set-piece whose clock is
frozen per screen -- since that decides whether its lighting is a bug, and note
that ActionSetCoastSprites is the one unresolvable class the menu reaches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Helicopters flew a clean path while spinning about their own axis -- heading
swinging +-40 to 57 degrees per quant, the model rolling through ~140, reversing
whenever the turn rate changed sign. The position was never wrong: traced, step
held 4.99-5.10 per quant throughout, which is why it reads as an animation fault
and is not one.

The bank is clamp(rotSpeed()/G2R(pathTrackingAngle), -1, 1) * additionalHorizontalRot.
Pre-2008 divided by pathTrackingAngle as written, in degrees; 2008 converted the
denominator to radians. G2R(30) = 0.524 against 30 is a 57x larger ratio, so the
clamp saturates and the bank becomes the whole of additionalHorizontalRot -- an
angle in radians, and Maelstrom's aircraft prms carry 20 of them.

Our own PathTracking.cpp:507 still has the old formula untouched, so the tree
already disagreed with itself; only the FormationController path converts, and
that is the one a flying unit in a formation takes.

Not a port regression. git log -L shows the line unchanged since the initial
commit, and Perimeter 2 reads the same 20 and 45 in three of its own flying prms,
so stock 2008 does this too. Hence MAELSTROM_DATA, with the #else byte-identical.

Measured after: peak heading swing 118 -> 11 degrees, peak roll 0.94 -> 0.18. The
reversal *rate* barely moves (11-24%), because what was wrong was the amplitude of
each correction, not how often they happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ivan-ushakov
ivan-ushakov force-pushed the feature/maelstrom-data-support branch from ea8767e to 85a1288 Compare August 8, 2026 17:08
ivan-ushakov and others added 5 commits August 9, 2026 11:53
…g class

cObject3dx's destructor asserted gb_RenderDevice3D, meaning "the device must
still exist while this object releases its GPU resources". That pointer is the
D3D9 device and has been permanently null on every platform since the retirement,
so the assert could only fail -- it fired for every object destroyed in a build
with assertions on, which is how it turned up: buried in a Windows 10 user's log
under a pile of unrelated ones. It was the last assert of its kind in the tree.

Not re-pointed at gb_RenderDevice. Destruction legitimately outlives the device
at shutdown, which is exactly what the sPtr buffer destructors already guard for,
so the same assert on the live pointer would fail there instead.

The same log named UI_ACTION_EXPAND_TEMPLATE, a Maelstrom UI action with no
counterpart in UI_Enums.h -- an eleventh class in the register's list, and the
second known to be actually reached. Non-fatal: XPrmIArchive reports, skips the
block and carries on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ulting

Loading every world in turn with -world found four that crash: Maelstrom's
TEST_Effects, TEST_Environment_Buildings, TEST_Environment_Trees and
TEST_World_Tutorial. All four die in vrtMap::getAlt (VMAP.H:122) on a null
vxaBuf -- the "water vxaBuf null crash" that has been open and unreproduced.

None of it is schema drift. Those four are .spg files with no world directory
beside them: no world.cls to open, so allocMem4Buf never runs and vxaBuf stays
null. vrtMap::load gets that right and returns false. GameShell::GameLoad called
it as a statement and dropped the result, so the mission went on to build a
universe over a heightfield that was never allocated.

Unconditional, because Perimeter 2 ships orphan worlds of its own -- cs_c1_open
and intro_01 have no directory either, so retail can reach the same fault.

The name goes out through dprintf as well as ErrH.Abort. Abort prints to stderr,
and stderr stops reaching the log after renderer init, so the abort on its own
turned a crash into a silent exit(1) -- which is barely better, since nothing
then said which world was at fault.

The rest of the pass is the good news: 47 of 51 worlds build their universe with
no assertions at all, across all four campaigns, the multiplayer maps, the
cutscene worlds and the menu. The register claimed two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GameOptions stores an option as `number = <index>` into a list that lives in C++
(GameOptionsSerialization.cpp), not in the data. The `comment` beside it looks
like the list but is only a label the engine never reads, so an index written by
one revision silently selects a different entry in the other.

Three lists changed shape. OPTION_SCREEN_SIZE 25 is 1920*1080 there and 1680*945
here, because we added 858*484 and 1680*945; OPTION_SHADOW 3 is High there and we
dropped Maelstrom's Circle; OPTION_LANGUAGE 0 is "Use Steam Language", which we
have no equivalent for. The other four indexed options have identical lists in
both games and are left alone.

Screen size is the one that shows. The wrong index changes the window aspect, and
with it which branch of the letterbox code runs -- and it scales the UI font,
since createFont computes fontSize_ * windowHeight / 768. A 16-point font at the
900-pixel window that follows asks FreeType for 19, which is the ":19" in a
Windows 10 user's UI_Font.cpp:80 assert.

The comment is Maelstrom's own list, so the conversion needs nothing external:
resolve the index to a name against it, then look that name up in ours. The
comment is rewritten too, and that is what keeps the pass idempotent -- leaving
the old list would make a second run resolve the new index against the old names
and convert twice. Verified: 25 -> 27, 3 -> 2, 0 -> 0, second run byte-identical,
and the file keeps all 161 lines and all 161 CRs.

A bad antialias index is a different fault and no offline pass can settle it:
those are filtered at run time against what the GPU reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Type

Maelstrom ships no TrueType at all -- zero .ttf in the distribution -- so the UI
ran on whatever face --font named, and the register recorded the lettering as a
substitution we could not avoid.

It pointed at the wrong file. The .xfont files beside the masters are only a
cache: cFontInternal::CreateTexture tries Load(name) first and, failing, builds
from the master and writes the .xfont back. There are six of those, at whatever
sizes one machine asked for, against fifteen *.font masters that are the real
source -- every face, any size.

A master is a 1bpp bitmap, laid out as the original's LoadFontImage reads it:

  "font" | int32 real_height | uint16 char_min | uint16 char_max
  per char: int32 width | uint8 bits[((width+7)/8) * real_height]

All fifteen parse with every byte consumed. Courier New.font reports one width
for every glyph, which is a free check that the width field is read correctly.

BitmapFont.cpp turns one into an ordinary FT::Font -- same atlas, same
charTable_, same metrics -- so nothing downstream knows the difference, and
createFont dispatches on the extension with the TrueType path untouched. All of
it is #ifdef MAELSTROM_DATA: verified absent from the Perimeter 2 binary.

Three things that are easy to get wrong. The masters are not one codepage: byte
0xC0 is a plain A in Russian/MAEL_small.font and an accented A-grave in the
English one, so the LocData directory picks it and the reverse map is built with
the same MultiByteToWideChar that built the char table. The whole cell is scaled,
padding included, as CreateImage did -- trimming to the ink would give a larger
font than Maelstrom shipped. And the greys are the downsample: the source is one
bit deep.

The converter now needs no --font, which retires the failure class it invited --
it never checked the named file existed, so a wrong path converted cleanly and
faulted later in FT::Font::size(this=0x0). --font survives as an override.

Verified: all three faces build from the masters at 23/45/34 px into 256x256,
1024x512 and 512x512 atlases, no assert; Perimeter 2 loads unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
selfCameraRestriction, cameraBorder and the flat CAMERA_* run were Environment
fields and are CameraManager ones now, so a pre-2008 world writes them in its
environment block and CameraManager::serialize never sees a name of them. Read
them there and hand them across, as the original did.

The global set in Scripts\Content\GlobalAttributes was half-arriving already --
this tree opens that node, so the shared names were read and only the renamed
ones defaulted: zoomMin 300 against the file's 50, heightMin 0 against 50, and
zoomDefault 300 against 500, which is the distance every mission opens at.

CAMERA_THETA_MIN is not a floor under the tilt. The tilt ceiling falls off with
distance, and the old pair are the two ends of that ramp -- MAX zoomed in, MIN
zoomed out -- so they pair with thetaMaxLow and thetaMaxHigh and the floor stays
0. The 2008 defaults for that pair are 60 and 18 degrees, which are Maelstrom's
own global values.

The zoom-speed trio is deliberately left unread: the dynamics were rewritten in
2008, not renamed, and no value of zoomKeyAcceleration reproduces a delta that
was not scaled by the distance. aboveWater goes false, the original having
tracked the ground with vMap.GetApproxAlt rather than the water surface.

Also corrects the register: FarPlane and NearPlane are the depth-of-field pair,
not camera fields, and Environment::serialize reads them already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ivan-ushakov and others added 4 commits August 9, 2026 20:08
…t matter

XPrmIArchive reports an unregistered class, skips the block and carries on, so
none of these had been sorted by whether anything reaches them. Logging str at
the result == -1 branch turns that into a measurement: seven of the eleven live
in Scripts/Engine/AiActionChainList and AuxDictionary, two files no engine
opens -- and grepping origin/Maelstrom for AiAction/AiCondition/AttributeReal
finds no C++ at all, so they were dead in Maelstrom too. ActionSquadMove is
live there but its only call site is in MISSIA.scr, which no world and no other
chain names. The other three are reached, and are ported:

ActionSetCoastSprites -- 14 call sites, one per screen of the main menu. Our
cCoastSprites::serialize is Maelstrom's Init() with the read folded into it, so
the apply half is split back out for the action to call.

ConditionObjectNearObjectByLabel -- measures from a labelled unit where the
surviving sibling measures from an anchor. Ten of its twelve uses are in AI
chains that c1_m1, c1_m7, c2_m1 and c2_m7 load, where an unregistered condition
reads as one that is never true.

UI_ACTION_EXPAND_TEMPLATE -- the pre-2008 UI_ACTION_LOCALIZE_CONTROL, expanding
the control's own caption rather than a string of its own. The in-game clock is
one of its six users: L_ASTRO TIME resolves to "{time_h12} : {time_min}
{time_ampm}", drawn literally without it. The caption is kept unexpanded beside
text_, as the original kept it, or the first update consumes the template.

Tooltips come back with them. A control carried its hover text and cursor
itself, in a transparent openBlock("hover"); 2008 collected the pair into a
UI_ACTION_HOVER_INFO action. Reading the old fields back into that action lets
findAction's control-then-state lookup stand in for the old hint() fallback,
and leaves the delay, the type match and the template expansion untouched. 757
controls name a tooltip key and not one names the action, so the 2008 path was
dead end to end -- 727 of them on the three race HUDs, not in the menu.

Verified: removing one REGISTER_CLASS brings ActionSetCoastSprites back 14
times; with all three registered, a boot to the menu and a load of the four
campaign worlds report nothing unresolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… own

copyToCubeFace acquired a command buffer of its own and submitted it on the
spot. flushTarget above it only *records* the face's render pass into
commandBuffer_, which is not submitted until EndScene -- so the copy ran first
and read pFaceTarget before anything had been drawn into it.

Every slot therefore received the previous face: the cube came out rotated by
one, and each lookup returned a neighbour. On the first pass, when nothing had
been rendered yet, all six faces took uninitialised magenta.

It hides well. The six faces of a still sky differ little, so the reflection
stays plausible -- it is simply the wrong direction. It surfaced only once the
water started sampling the cube, asking for the sky overhead and getting the
horizon band, whose lower half is the fone colour below the horizon: a flat
sheet with no sun in it. The other consumer, the 116 Maelstrom models with
is_reflect_sky, had been reading a neighbouring face all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults, both leaving the water a flat sheet of colour.

The wave maps never loaded under MAELSTROM_DATA. waves.dds / waves1.dds are
read in one place, cWater::serialize's SERIALIZE_PRESET_DATA branch. Perimeter 2
reaches it through loadPreset() and Presets\global.set; Maelstrom ships no
Presets directory, its loadPreset() descends into GlobalAttributes'
environmentColors alone, and the world is read under SERIALIZE_WORLD_DATA. The
branch never ran, both handles stayed null, and the renderer bound its flat 1x1
stand-in -- a zero slope, which is no waves in any technique. The pre-2008
engine loaded them in the constructor (origin/Maelstrom:Water/Water.cpp:88);
2008 made them serialized fields and moved the load into the preset. Load them
in the constructor again, with no #ifdef: GetElement3D is a cache, so Perimeter
2's later call only takes a reference, and no shipped preset names
bumpTextureName in either game.

WATER_EMPTY was standing in for the reflections-off look. It is not that.
ca9aa43's setTechnique picks, on any PS2.0 card,

    IsReflection() ? WATER_LINEAR_REFLECTION : WATER_REFLECTION

and WATER_EMPTY is the no-PS2.0 path. Maelstrom ships OPTION_REFLECTION = false,
so its water ran a flat vPS11Color whose only variation is an alpha crest term
that saturates past ~16% depth. Port WATER_REFLECTION as a third build of
water.{vert,frag}.hlsl (CUBE=1, from water_cube.{vsl,psl}) with its own pipeline
pair and a TextureCube on stage 2, and let setTechnique make the original's
choice.

Note there is no glint term in water_cube.psl: the sun sliding over the crests
is the sun in the cubemap, displaced by cube.xy += (tex0.xy+tex1.xy)*0.3.
uv_mirror is normalize(camPos - pos) with dir.z -= pos.z -- the eye vector
mirrored about z=0, not a surface normal -- ported verbatim, so at a low camera
the reflection is a horizon one rather than a mirror of the zenith.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createFont's .font test called strcasecmp. That is the POSIX name; MSVC's CRT
has only _stricmp, so the line does not compile with Visual Studio. It went the
wrong way through the compat layer: Platform/WindowsAPI.h is force-included
everywhere and maps the MSVC spellings onto POSIX in its non-Windows branch
(#define stricmp strcasecmp), which makes stricmp the portable name here and
strcasecmp the one that only builds off Windows. The rest of the tree already
writes stricmp.

CI did not catch it and could not have. The call sits inside #ifdef
MAELSTROM_DATA, the option defaults to OFF, and none of the three workflows
pass -DMAELSTROM_DATA=ON -- so the preprocessor deletes the block before any
CI compiler sees it, on every platform. The whole Maelstrom path is uncovered
that way, BitmapFont.cpp included: it is listed unconditionally in
Render/CMakeLists.txt but its entire body is guarded, so CI builds it as an
empty translation unit. A green Windows run is not evidence that Maelstrom
code compiles under MSVC; only a local MAELSTROM_DATA=ON build is.

strcasecmp was the only POSIX-only spelling in the ~30 guarded files.

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