Skip to content

The Frontend Menu Machine

codingncaffeine edited this page Jul 17, 2026 · 2 revisions

The Frontend Menu Machine

Dungeon Siege's main menu is not a 2D UI. It is a miniature 3D machine: nine skinned meshes sharing one coordinate space, posed and morphed by skeletal animation clips, textured from swap tables, and scored with its own machinery sound set. Screens are poses of the machine; navigating between screens runs it — gears turn, planks fold, a title drum rolls, a window door slides open.

This page documents how the machine works, and — because it took a long stretch of blind iteration before we learned to measure instead of guess — how each piece was actually figured out. It is written to a specific bar: §8 (the build sheet) plus the timing and sound sections should be sufficient to reimplement the menu from scratch against a retail install, without reading our code. Everything here is reproducible with the siegefx CLI; nothing comes from leaked source. If you are reimplementing another vintage engine's 3D menu, the workflow section at the bottom is the transferable part.

1. The parts

All meshes live in Objects.dsres under art/meshes/gui/front_end/menus/main/ with the prefix m_gui_fe_m_mn_3d_; their animation clips sit next door under art/animations/.../a_gui_fe_m_mn_3d_<mesh>_<clip>.prs.

mesh bytes role
backdrop 9,229 the 3×3 stone-machinery wall; its bbox is the screen frame (X ±1.64, Y ±1.64 — square)
leftside 170,765 left gear pillar + door tiles + the door-socket gear/piston ornaments
rightside 187,984 right pillar with the sliding hero-window door (ASP v2.2 — one BTRI header word, not two)
mainmenu 149,868 inner panel + chains + the title drum (§3)
menubars 200,889 the wood button rows + their label flaps (§3)
backbutton 34,825 the bottom block: EXIT ↔ BACK ↔ PREVIOUS+NEXT morphs (§3)
heromenu 27,425 hero-creator spinner column
logo 6,769 title splash: wordmark + the sword that drops into the log
loadmap 44,925 the Load Game window: marble frame + world map + selector spikes

Rendering is painter's algorithm — depth test off, draw order decides layering — with backface culling on. The culling is load-bearing: every "this plate shows, that plate doesn't" mechanism in §3 works by rotating geometry to face toward or away from the camera.

Projection receipt: letterbox the viewport to the backdrop box's aspect, scale by 1.30 overscan (retail framed only the central 1/1.3 of the box). At 800×600 that lands on 237.7 px per mesh unit: screen_x = 400 + mesh_x·237.7, screen_y = 300 − mesh_y·237.7. Memorize that pair — it converts any traced bone position straight into screenshot pixels, and it is how every placement claim below was checked. The 2D widget layer (.gas rects, authored in an 800×600 interface space) maps 1:1 onto that central region — not onto the overscanned box; mapping it wrong inflates every widget by 1.3× and was one of our earliest layout disasters.

2. Screens are poses, transitions are clips

Clip names follow one grammar: <mesh>_<from>2<to> over a set of two-letter state codes. The engine hardcodes the state machine (there is no frontend skrit — we searched; zero matches in either tank), so the codes had to be decoded from the clips themselves:

code screen
mm main menu
sp single-player submenu
sng choose hero (the character creator)
cd choose difficulty
lg load game
lm load map (multiplayer world picker)
mp multiplayer provider menu
dsm multiplayer session/staging tree
e / b / pn / ac backbutton poses: EXIT / BACK / PREVIOUS+NEXT / ACCEPT

The sng/cd row is the hard-won one. For months we read cd as "character design" and held the creator's title drum at mainmenu_sng2cd@1 — and kept finding either a blank plaque or the word DIFFICULTY where CHOOSE HERO belonged, then "fixing" it with subset masks. The decoding receipt is one command:

siegefx asp trace-pose m_gui_fe_m_mn_3d_mainmenu.asp a_gui_fe_m_mn_3d_mainmenu_sng2cd.prs 1.0
   → PanelBASE3 (CHOOSE HERO)  base Y=1.160, tip Y=1.374   — tip UP   → backface-culled
   → PanelBASE7 (DIFFICULTY)   base Y=1.162, tip Y=0.952   — tip DOWN → visible
siegefx asp trace-pose ... mainmenu_sp2sng.prs 1.0
   → PanelBASE3 tip Y=1.135 — tip DOWN → CHOOSE HERO visible

So sng2cd rolls the drum from the hero screen to the difficulty screen, and the creator's true settled pose is sp2sng@1. One trace ended a mystery that had survived multiple rewrites. The general lesson: when two states show the wrong content in each other's slots, suspect the state labels before the bones.

3. The three flip mechanisms

Everything the menu "changes" between screens is one of three tricks, all driven by bones + backface culling:

The title drum (mainmenu). The title plaque is a rolodex on the spindle: ten PanelBASE/TIP bones each carry one plate, textured from two atlases split left/right (text-01l/r.raw rows: MAIN MENU · SINGLE PLAYER · CHOOSE HERO · LOAD GAME · OPTIONS; text-02l/r.raw: the DIFFICULTY / LOAD MAP / multiplayer tree). The plate whose tip hangs below the spindle faces the camera; every other plate faces away and is culled. Comparing base bone positions makes two plates look co-located and sends you chasing phantom "bleed" bugs — the bases all sit on the spindle. Compare tips.

The label flaps (menubars). 17 subsets: panel chrome, five wood rows, and label quads skinned to each screen's row bones. Rows fold like planks between states; because the label quads ride the same bones, drawing the text subsets with the transition clip makes the letters fold with the wood, and the folded-away faces cull themselves. Each row's label subsets come in pairs — the even ones sample text-menubars1.raw and are the ones to draw; the odd ones bind a texture whose on-disk content belongs to a different screen (an engine-side texture indirection we have not reverse-engineered; skipping the odd subsets is the working answer).

The backbutton morphs (backbutton, internal name exitback, 11 subsets, 13 bones). The little block at the bottom is four screens' worth of controls in one mesh: the EXIT↔BACK flip is two plates on a spindle (e2b/b2e — tip direction again), and BACK↔PREVIOUS+NEXT telescopes the side plates outward (b2pn: PrevTip X→−0.80, NextTip X→+0.80, landing the text quads exactly over the .gas hit rects at screen x 262–339 / 461–562). The part that fooled us twice: at the BACK pose, the folded-in prev/next plates are the ball-knob handles flanking the plate. Masking them off "because art_mapping only lists subsets 4+8 for this widget" strips the button of its surrounding design — art_mapping.gas lists the hover-swap subsets, not the full chrome roster. Two screens shipped bare BACK plates for that reason before the retail screenshot comparison caught it.

4. Draw order: authored subset order is the layer order — except when it isn't

With depth off, subsets paint in authored order, and DS1 authored them knowing that. Two standing traps, plus the case study that finally taught us to respect the authored order:

  • Shadow subsets — nearly every chrome mesh ships a translucent shadows subset authored last (leftside 5, mainmenu 5, heromenu 12, backbutton 10, menubars 16, loadmap 2). Drawn in place, it paints grey haze over the content. It belongs in a pre-pass underneath (or masked off where it lands across UI).
  • Behind-the-body rigsloadmap authors its silver pole rig after the window body, but the poles run behind the frame; only the tips and hanging knobs should peek out. Pass order for that screen: shadow → poles → body.
  • The socket gears (case study). The door tiles of leftside have two round sockets that, in retail, hold ornate gear wheels with gold cog trim, sitting right beside the BACK button. Ours were empty rings for months. The cause: an early "fix" reordered the pillar draw into body-then-doors because doors-over-columns looked wrong at the seam — which buried subset 4 (background-02 texture, DoorBase/PistonBase bones), the gear-and-piston ornament layer that DS1 paints on top of the doors. The receipt that settled it: trace-pose puts subset 4's bbox at mesh (±0.33, −0.84) ≈ interface (322/476, 499) — exactly the sockets — and a retail screenshot shows the gears over the door tile. Authored order restored (doors 0,1 → columns 2 → machinery 3 → ornaments 4), shadows in an under-pass, and the machine got its gears back. When a subset seems misplaced, the answer is a trace and a retail screenshot, not a reorder.

The right pillar deserves its own line: rightside.asp is the only frontend mesh saved as ASP v2.2, whose BTRI section stores one u32 per subtexture (cornerStart) where v2.3 stores two. Until the parser learned that, the mesh rendered stretched, and we shipped a mirrored left pillar in its place — symmetric, and wrong: DS1's right pillar is asymmetric, carrying the framed window arch whose door slides open (rightside_open, 3.33s) to reveal the 3D hero preview on the creator screen.

5. Timing: every clip at its own speed, and no idle anywhere

The clip catalog (siegefx prs info over all 69 frontend clips) clusters into exactly four lengths, and the distribution is the choreography:

length clips role
0.6667s mainmenu_up/down, menubars_alldown/allup the chrome rolling away/back (not yet used — see §8)
1.5s menubars_flyin rows drop in (boot)
1.3333–1.6667s all mainmenu_*2*, most menubars_*2*, loadmap_down/up, logo-enter (2.17s) the panel morphs — what you read and click
3.3333s all backbutton_*2*, rightside_open/close, leftside/rightside_flyin the machinery flourishes — gears, doors, block flips

Two facts fall out of that table. First, transitions must run each clip at its authored length: normalizing them to one shared window (our original implementation) played the 3.33s backbutton flip at 3.3× speed and cut the boot gear flourish off halfway — the menu read as "nothing animates." The engine now keeps a single since-transition clock; each mesh evaluates its clip at native speed and parks on its end frame; a transition state lasts as long as its panel clips (input unlocks at ~1.67s) while the long machinery clips keep turning as tails into the settled screen — the settled state draws the same clip on the same clock, so the motion completes without a pop. Arriving somewhere from two directions picks the tail clip by arrival path (Single Player draws the block as e2b when you came from Main Menu, pn2b when you backed out of Load Game).

Second, there is no authored idle motion anywhere: every *_default and *_begin clip is a single-key static pose (23 of 23 bones, one rot + one pos key each). The pillar gears spin during flyin (58 rotation keys on the small gears, 34 on the big ones, a 27-key piston pump), the window gears during owin/cwin — and then the machine stops. A menu that idles still is authentic; inventing an idle loop would not be.

The boot sequence is the one place the timing is authored across files: frontend_lights.gas sets to_main_menu ramp_duration = 6.0, the logo drop is 2.17s + exit 0.58s, the fly-in flourish 3.33s — and the assembly sound s_e_frontend_menu_enter.wav runs 5.71s. The numbers interlock; that agreement is what confirmed the sound mapping (§6). Related behavior decision: skipping the intro with Esc (or nointro) lands on the fly-in, never past it — the chrome assembling is the menu arriving, so it plays on every boot path.

6. Sound: the machine has its own foley

Sound.dsres /sound/effects/ ships a dedicated frontend set. Durations are header math (22050 Hz stereo 16-bit), which is also how the roles were assigned — the packed retail exe surrenders no trigger strings, so names + durations + the authored 6.0s ramp are the evidence:

sample duration wired to
s_e_frontend_menu_enter 5.71s boot fly-in (the machine assembling; ≈ the 6.0s ramp)
s_e_frontend_menu_open 5.71s (different audio) submenu folding open (main → SP / MP)
s_e_frontend_menu_close 4.89s submenu unwinding (SP / MP → main)
s_e_frontend_menu_roll 2.99s inner screen rolls (load game, creator, difficulty; ≈ the 3.33s clip family)
s_e_frontend_logo_flyin / flyout sword drop / withdraw
s_e_frontend_big/small/tiny/arrow_button clicks (main buttons / dialogs / spinners)

One implementation stumble is worth recording because it silenced the whole system: our state-edge listener compared the frontend state before and after the state machine's tick — but click-driven transitions set their state from the input handlers, after that comparison in the frame. Every click-initiated transition was invisible to it, so no transition ever made a sound (session logs showed only settle edges like MainMenuToSp → SinglePlayer, never entries). The fix is structural: diff against the last state the listener processed, not a same-frame before/after pair. If you build an edge-driven anything, make sure every writer of the state is upstream of the edge detector — or diff against memory.

7. Textures: one mesh, many faces

ui/config/art_mapping/art_mapping.gas (Logic.dsres, 7,858 bytes of plain text) is the master key: for every widget, per mouse state, it lists which mesh subsets rebind to which texture ([button_sp_back] mouseover → 4 = exitback-up; 8 = text-small-up, etc.). The engine prepends b_gui_fe_m_mn_3d_ and appends .raw; subset keys correspond to the -mapN suffixes on the mesh's authored texture names, which all alias one underlying atlas after the suffix is stripped. Every engraved label in the frontend — EXIT, BACK, PREVIOUS, NEXT, HERO, NAME, the creator row names — is baked art in text-small.raw, not rendered text; substituting a font renderer reads instantly as wrong. We burned an estimated two hundred thousand tokens' worth of UV-crop guessing on the EXIT button alone before finding this file. Read the swap table first. It has been the first step of every screen since, and no screen built that way has needed a revisit.

Two perennial coordinate traps: .raw stores rows bottom-up (mentally V-flip every atlas), and .gas uvcoords are bottom-up GL V while our icon path takes visual top-down V (visual_v0 = 1 − gas_v1).

8. The build sheet

Everything above explains the machine; this section is the recipe to rebuild it. A renderer that implements this table, the formulas, and the §5/§6 timing/sound rules reproduces the menu.

Frame skeleton (every frontend frame, in order):

  1. Clear to opaque black — the chrome has transparent gaps (backdrop cell seams, pillar edges) that must not show the world behind.
  2. GL state: depth test off · blending SrcAlpha, OneMinusSrcAlpha · backface culling on, CCW front faces · scissor to a centered box of aspect ≈1.32 (height-constrained on widescreen) so out-of-frame authored geometry never bleeds into the letterbox bars.
  3. Draw the current state's rows from its table below, top to bottom (painter's order is the layering).
  4. Draw the 2D widget layer (engraved-label crops, list boxes, edit boxes) through the interface mapping.
  5. Hover/press feedback = redraw only the hovered widget's art_mapping.gas subsets with the -up/-down texture variants, on top.

Projection. Reference box = backdrop bbox (X ±1.64, Y ±1.64). Letterbox the viewport to the box's aspect → target square side T = min(vw, vh); scale s = (T / 3.281) × 1.30; then screen = center + (mesh_x·s, −mesh_y·s). Interface mapping for 2D rects: letterbox the viewport to 4:3, scale = letterbox / (800, 600) per axis, origin = center − (400, 300)·scale — identity at 800×600, and never the overscanned box.

Clock. One timer t restarts at every transition entry and keeps counting into the settled state. Every clip evaluates at hold = clamp(t / clip_length). Transition states end (input unlocks) at their panel clip length; the 3.33s machinery clips finish as tails. Reverse transitions use the mirror clip (sp2mm for mm2sp, etc.) — every table below lists the settled pose; the transition into it is the same rows with the clock running.

Boot chain: IntroMicrosoftIntroGaspowered (each 0.8s fade-in + 1.5s hold + 0.8s fade-out, 3-panel 640×480 splashes from intro_microsoft/gaspowered.gas) → Bink stub 1.0s → LogoDrop (logo-enter 2.1667s, play logo_flyin) → hold 1.0s → LogoExit (logo-exit 0.5833s, play logo_flyout) → MenuFlyIn (§5 fly-in clips at native speeds, play menu_enter) → MainMenu. Esc anywhere in the chain jumps to MenuFlyIn, never past it.

Per-screen recipes (subset indices are 0-based; "native" = 3.33s clip on the shared clock; shadows subsets are always either an under-pass or omitted — leftside/rightside 5, mainmenu 5, menubars 16, backbutton 10, loadmap 2):

Main Menu

draw clip @ hold subsets
backdrop bind all
left+right pillar shadows leftside_default / rightside_default @0 5
leftside, then rightside same @0 0–4 in authored order
mainmenu mainmenu_sp2mm @1 0–4 (drum bone-Z shows MAIN MENU)
menubars menubars_sp2mm @1 0–15
EXIT widget backbutton bind pose, rect-mapped 0,1,2,3,8 — hover swaps 3=exitback-up, 8=text-small-up
2D legal text 50,485–750,585 · credits glyph 16×16 anchored bottom-right

Single Player (enter: play menu_open; leave to MainMenu: menu_close)

draw clip @ hold subsets
backdrop + pillars as Main Menu
mainmenu mainmenu_mm2sp @1 0,1,2 (drum → SINGLE PLAYER)
menubars menubars_mm2sp @1 0–5 settled; + 6,8,10,12,14 while in motion (labels ride the planks)
backbutton pass 1 e2b — or pn2b when arriving back from Load Game / the creator — @ native 1,2 (the folded knobs)
backbutton pass 2 same 0,4,8 (backing, BACK plate, label)
widgets (single_player.gas) start_new_game 280,241,518,290 → show_character_selection · load_game 280,318,518,367 → load_single_player · sp_back 360,568,439,597 → back_to_main

Load Game (enter/leave: menu_roll)

draw clip @ hold subsets
shell as Single Player, mainmenu sp2lg @1 (drum → LOAD GAME), menubars stays mm2sp @1 chrome-only
backbutton b2pn @ native 0–9 minus 5, 9 (name plate would land across LOAD/DELETE), 10 off
loadmap 3-pass loadmap_down @1 shadow 2 → poles 1 → body 0
2D save-name text 226,172,470,252 centered · listbox 275,262,518,483 (row height 13, ink 0x959290, selection alpha 0.5) · thumbnail 488,179–568,238 under a b_gui_cmn_brd_01 box · LOAD 299,492,386,518 · DELETE 413,492,500,518 · PREVIOUS/NEXT bare hit rects 237,575,338,595 / 461,575,562,595

Choose Hero — the creator (enter/leave: menu_roll)

draw clip @ hold subsets
backdrop + left pillar as above
rightside rightside_open @ native (door slides, gears turn; stone + hero visible through the arch) shadows 5 pre-pass, then 0–4
mainmenu mainmenu_sp2sng @1 0,1,2 (drum → CHOOSE HERO)
menubars menubars_sp-up @1 (rows parked off-top; labels 12,14 ride during motion) 0–5
backbutton b2pn @ native 0–9 minus 10
heromenu heromenu_begin @1 spinner column + engraved row labels (subset 14)
2D six spinner rows: arrows at x 176–213 / 340–377, row centers y ≈ 214/265/315/365/413/463 · hero preview FBO blit into 408,73,649,494 · name edit 297,518,530,542 (font 12p copperplate-light, max 14 chars) · PREVIOUS/NEXT as Load Game

Choose Difficulty (enter/leave: menu_roll)

draw clip @ hold subsets
shell rightside rightside_close @ native (door closes again)
mainmenu mainmenu_sng2cd @1 0,3,4 (text-02 drum → DIFFICULTY)
menubars menubars_lm2cd @1 3,4,5 (the three rows)
backbutton pass 1 / 2 pn2b @ native 1,2 then 0,4,8 (full BACK assembly)
2D EASY/NORMAL/HARD engraved labels = UV row-crops of text-menubars3.raw with -up/-down hover variants

Multiplayer provider (enter: menu_open; leave: menu_close)

draw clip @ hold subsets
shell as Main Menu
mainmenu mainmenu_mm2mp @1 0,3,4 settled (both text atlases ride mid-roll)
menubars menubars_mm2mp @1 0–5 (+ label flaps in motion)
backbutton e2b @ native, two-pass 1,2 then 0,4,8
2D provider labels = text-menubars2.raw row-crops (ZoneMatch row drawn dimmed, not clickable)

Input rules. Hit rects come from each screen's interface .gas through the interface mapping; input is active only in settled states (clicks during a morph are dropped); every button plays big_button on press; hover redraws only that widget's art_mapping subsets. The state machine advances on the notify() names listed in the gas files — the full name→transition table is in frontend_lights.gas's header comment, which is the closest thing to engine documentation DS1 ships.

To pull any referenced file yourself: siegefx tank extract "<install>/Resources/Logic.dsres" "/ui/interfaces/frontend/single_player/single_player.gas" out.gas — and the same for art_mapping.gas, frontend_lights.gas, or any mesh/clip/texture named on this page.

9. What's still open

  • mainmenu_up/down + menubars_alldown/allup (0.67s) are almost certainly the chrome rolling away when a game launches — currently unused; the menu cuts to loading.
  • Esc inside submenus should probably act as Back (retail behavior unverified); today it is inert there.
  • The multiplayer map-chooser (lm/dsm tree, loadmap + mainmenu_mp2dsm) is unbuilt.
  • The main menu's EXIT is still drawn as a rect-mapped widget at a hand-tuned position rather than the chrome block at its authored spot (with backbutton_flyin on boot) — a fidelity/regression tradeoff not yet taken.

10. How we actually figured this out (the transferable part)

The early period was expensive: guessing rects from screenshots, iterating UV crops, re-deriving per-button mechanics one visual bug at a time, and "fixing" correct data with masks. What replaced it is a measurement loop, and it is the reason the later screens (Load Game, Difficulty, Multiplayer) landed in single sessions:

  1. Get the retail reference. A screenshot of the real screen, and the conversion constant (237.7 px/unit at 800×600) to move between pixels and mesh space.
  2. Read the data that already answers the question. The screen's interface .gas (widget rects, notify names), art_mapping.gas (texture swaps), frontend_lights.gas (state names, ramp timings). The answer is usually shipped in plain text.
  3. Trace before writing code. siegefx asp subsets (which bones own which subset, which atlas it samples), siegefx asp uv-by-bone (which atlas row each bone carries), siegefx asp trace-pose <mesh> <clip> <t> (post-skin bounding boxes and bone positions at any clip time — the single most informative command in this entire effort), siegefx prs info/compare (clip lengths, key counts, which bones actually move).
  4. Render receipts, not builds. SiegeFX.Runtime --frontend-shot <Logic> <Objects> <state> [--t=SECONDS] renders any menu state — including mid-transition frames — to a PNG through the real renderer in a hidden window (test-all option 115). Comparing two --t frames proves a gear turns; comparing against the retail screenshot proves placement. No booting, no clicking through menus, no "I think it looked different."
  5. When a still can't answer it (motion feel, audio), a human plays it. The receipts shrink that list to the things that genuinely need eyes and ears.

The pattern generalizes to any data-driven vintage engine: the shipped bytes are the specification, the original renderer's conventions (draw order, culling, coordinate handedness) are part of that specification, and every hour spent building a tracer pays back the first time it turns a week of eyeballing into one command.

It is also, deliberately, a provenance ledger. Every fact on this page traces to one of three sources: bytes shipped in a retail install (meshes, clips, gas files, wavs — all quoted with sizes and readable with the CLI), black-box observation of the retail game running (screenshots, timing), or arithmetic over the two. That chain — retail data → runnable receipt → implementation — is the clean-room position in practice, and pages like this one are what make it auditable rather than asserted.

Clone this wiki locally