Skip to content

Art pass slices 1-2: visuals schema, draw-call counter, inspect_gltf and vendored KayKit/Inter - #52

Merged
tonianev merged 16 commits into
mainfrom
claude/confident-hopper-ho6dz1
Oct 28, 2026
Merged

tonianev merged 16 commits into
mainfrom
claude/confident-hopper-ho6dz1

Conversation

@tonianev

Copy link
Copy Markdown
Owner

What and why

First two slices of the pre-stream art pass (ROADMAP "Art pass (pre-stream)", owner decisions of 2026-10-26). This PR lays down the tooling and the assets. The game looks the same, apart from selection rings and attrition diamonds, which are now shared meshes. Models start being drawn in slice 3.

Slice 1: schema, counter, bench

  • visuals.ron schema covers buildings, sites, rubble, the Seat flag, terrain props and team_swatches, with validators and negative tests.
  • New flags:
    • sim-cli data-check --assets/--no-assets
    • --assets-dir / EONMARK_ASSETS, --window-size
    • --grid and the G key; the grid is now off by default (owner decision 11).
  • art_bench scenario: two full Towns and 400 units fighting.
  • Draw-call counter (draw_stats.rs, --draw-stats, a dev panel row, exit lines) built on Bevy 0.19.1's public API only. scripts/draw_trace_xcheck.sh cross-checks it.
  • Rings and diamonds are now shared meshes. art_bench peaks at 42 draws, down from 241, and the count no longer grows with selection size.
  • render_smoke.sh always passes --draw-stats and takes an optional SECONDS argument.

Slice 2: tooling and assets

  • tools/inspect_gltf, a new workspace member with no glam: inspect, swatches, palette, bake, check.
  • Download and pinning:
    • tools/assets/sources.tsv pins 104 rows by sha256.
    • scripts/fetch_assets.sh checks the licence texts first and fails closed on any pin mismatch.
    • just fetch-assets and just art-bake.
  • Vendored assets, 72 files and 2.99 MB in total, each with its own ATTRIBUTION.md row:
    • KayKit Medieval Hexagon (CC0 1.0): blue buildings, the windmill, construction stages A/B/C, rubble, flag and props, 14 nature models, both licence texts.
    • Inter 4.1 (OFL 1.1): InterVariable.ttf and OFL-Inter.txt.
  • scripts/check_assets.sh:
    • --self-test injects an unattributed file and corrupts one pinned byte.
    • New rule 5 checks every vendored file against its sha256 pin.
  • CI's check job runs the self-test and inspect_gltf check data assets. The engine-boundary loop now covers inspect_gltf.

Review: a four-lens review raised 16 findings; 14 were confirmed and fixed and 2 refuted. The one blocking finding was rings and diamonds z-fighting: diamonds now sit at ATTRITION_Y = 0.025, and a compile-time assertion keeps the height order grid < diamond < ring < ghost.

Determinism: no fixture or .hash changed, and neither did rules_version (25) or SIM_VERSION (17). visuals.ron sits outside rules_hash (ADR 0003).

No ROADMAP acceptance box is ticked here; AP1 to AP10 are ticked in later slices.

Tier

Data PR

  • cargo run -p sim-cli -- data-check data passes: OK rules_version=25 rules_hash=0xfb0d0b8986fc8865 ... visuals=units:8 buildings:8 terrain:0 models=0
  • cargo test -p sim passes: 313 passed, warm 7.4 to 9.6 s
  • rules_version unchanged: no golden replay hash changed

Docs PR

  • typos passes
  • scripts/check_trademark.sh passes
  • Links are relative paths

Code PR

  • just ci passes locally: RUSTDOCFLAGS="-D warnings", fmt, both clippy runs, game 196 tests, inspect_gltf 23+2, rules 140, data_check 16. cargo-machete, cargo-deny and nextest are not installed locally; CI runs them.
  • Tests cover the change: schema negatives, art_bench, draw_stats, the ring/diamond height test, the selection-panel cap test, the grid test through GroundPlugin, and every inspect_gltf check rule (mutation-tested).
  • No f32/f64/HashMap/HashSet/Instant in sim, rules or ai
  • No Bevy/glam/wgpu/winit outside crates/game. Cargo.lock gains only the inspect_gltf package; gltf and png were already in the graph through Bevy.
  • Bevy doc links pinned to 0.19.1

Asset PR

  • scripts/check_assets.sh passes: pins ok (72 vendored files match tools/assets/sources.tsv), ok (74 files, 3011868 bytes)
  • Per-file ATTRIBUTION.md rows (path, author, source URL, licence, modifications, download date)
  • CC0-1.0 and OFL-1.1 only; OFL-Inter.txt sits beside the font
  • Every file is under 2 MB

Licensing and disclosure

  • Contribution licence agreement: for the owner to tick.
  • Original assets: none in this PR. Every asset is third-party CC0 or OFL.
  • AI assistance: written by Claude Code agents with adversarial review, per AI_CONTRIBUTIONS.md. No AI-generated art: every vendored file is unmodified KayKit or Inter. Owner to confirm the review.

How to verify

just ci
scripts/check_assets.sh && scripts/check_assets.sh --self-test
cargo run -p inspect_gltf --release -- inspect assets/models/kaykit_hexagon/buildings/blue/building_castle_blue.gltf   # 5,659 triangles
just verify                                     # 11/11 OK, hashes unchanged
git diff --stat 15f1616..HEAD -- crates/sim/tests/fixtures   # empty
just render-smoke art_bench '' 300 120          # draws max 42 under lavapipe

Lavapipe baselines (also in BUILD_TIMES):

Scenario Max draws Max triangles (main view)
unit_looks 29 33,636
enemy_outpost 22 51,396
art_bench 42 69,836

Not re-run in the final round: draw_trace_xcheck.sh (it needs a second Bevy build of about 4 GB). It passed in both GPU-preprocessing modes on the same counter code, with the counter within 29 draws of the trace over about 300 frames.

Open before slice 3: team-colour floors. Six of the eight chosen buildings fall under the plan's 100‰ team-area floor, and the Mangonel's catapult nodes sit at about 25‰ against 200‰. See ROADMAP Notes and ART_STYLE "Budgets".


Generated by Claude Code

claude added 16 commits October 28, 2026 00:19
data/visuals.ron gains a required buildings map (every kind is
Primitive(Cuboid), the M3b box, so the look is unchanged) and empty
team_swatches and terrain tables. Visual::Scene gains nodes, parts, team
and fallback; Visuals gains the optional sites, rubble, seat_flag, terrain
and team_swatches tables. Visuals::validate checks every kind and key,
the Scene path, scale, yaw, node and part rules, building and site looks,
team swatches, rubble, flag and terrain ranges, and the 1.4x silhouette
rule over the live file, each naming its field, with negative tests.

sim-cli data-check checks that every Scene file exists under --assets
(default <data>/../assets), --no-assets skips it, and the OK line ends
with visuals=units:8 buildings:8 terrain:<n> models=<n>.

The presenters read the schema: a unit Scene draws its fallback, a
building is the box for Primitive(Cuboid) and for a Scene. The present.rs
look tests measure the M4b looks frozen as M4B_LOOKS, so a visuals.ron
edit never fails a Rust test. visuals.ron is outside rules_hash (ADR
0003): no rules_version bump, no fixture change.
art_bench (seed 40, standard start on plains_1v1) is the draw-call and
frame-time scene of the art pass: P0 places eleven complete buildings
and three sites around its Seat and P1 the mirrored eleven, so each side
has twelve complete buildings; each side spawns 195 units (60
Skirmishers, 40 Shieldbearers, 40 Bowmen, 24 Outriders, 10 Mangonels, 6
Wains, 10 Yeomen, 5 Scribes) in groups of ten east of P0's Town. P0's
start Yeomen build two of the sites from tick 2, the armed units
attack-move at each other at tick 20 and three of P0's buildings lose a
third of their hit points at tick 30. It is a scenario, not a fixture;
NAMES has 17 entries.

The test checks the Seats, the 2 x 12 complete buildings and 3 sites
with their ids and mirrored tiles, the 400 units with their ids, kinds
and spawn areas, and that no command is rejected through the damage beat.
draw_stats.rs counts the draw calls each frame in the render world from
Bevy 0.19.1's public phase data: one per indirect-parameters entry, the
bins and unbatchable entities of views with NoIndirectDrawing, and the
sorted Transparent3d, Transmissive3d and TransparentUi items walked as
render_range walks them. The dev panel shows `draw calls <last> (max
<max>) / 500 ind dir sorted` (amber from 500); with --draw-stats (always
in dev builds) the exit prints `frame_stats: draws ... mode=` (and
fight_max in the fight window) and `frame_stats: triangles ...`.
EONMARK_WGPU_DISABLE turns wgpu features off for the trace cross-check,
scripts/draw_trace_xcheck.sh, which compares the counter with Bevy's
detailed_trace draw lines in both modes.

The game loads models from Cli::resolve_assets_dir (--assets-dir,
EONMARK_ASSETS, the checkout's assets/, assets/ beside the executable)
through AssetPlugin, not Bevy's crates/game/assets under cargo run.
--window-size sets the logical window size and frame_stats prints the
`window logical= physical= scale=` line. The debug grid is off by
default (ShowGrid; --grid, G toggles it). art_bench opens at 60 m over
P0's Town and the fight, its director selects every P0 unit at tick 40,
and prints_fight_lines (the enemy_outpost pair and art_bench) replaces
is_enemy_outpost.

render_smoke.sh always passes --draw-stats, requires the draws line,
prints the draws, triangles and fight lines and takes an optional sixth
SECONDS argument (just render-smoke too). ui.md documents the counter;
ROADMAP gains the "Art pass (pre-stream)" section with its acceptance
list.
A retained gizmo is its own Transparent3d item and is never batched, so
every selection ring and every attrition diamond was one draw call: with
all 200 of P0's units selected, art_bench read max=241 draws with
sorted_max=210 under lavapipe. Art-pass decision 11 makes both one flat
annulus mesh and one unlit material each (selection::ring_mesh, 48
segments for the ring, 4 for the diamond, inner radius 0.85 of the
outer; RingMesh and MarkerAssets), with NotShadowCaster, still one
top-level entity per UnitId and never a child or Pickable. Any number of
rings or diamonds is now one bin: the same art_bench run reads max=42
with sorted_max=10. Move and attack markers, supply rings and the
annexation arcs stay gizmos.

Tests: rings_and_diamonds_are_one_mesh_and_one_material_each and
rings_and_diamonds_have_no_parent drive the real sync systems over
capture_seat with three bleeding Bowmen; the marker-shape test now reads
the diamond mesh.

Docs: ui.md, ART_STYLE.md, ARCHITECTURE.md and macos-packaging.md
describe the meshes; IMPLEMENTER_NOTES gains the known traps of the art
pass (the asset root under cargo run, retained gizmos at one draw each,
no draw-call counter in Bevy or wgpu).
BUILD_TIMES gains the lavapipe render-smoke rows the later slices
compare against (boxes for buildings, the M4b looks, mesh rings):
unit_looks 29 draws a frame, enemy_outpost 22 (fight_max=22), art_bench
max 42 (fight_max=42, all 200 of P0's units selected; TICK 300 and
SECONDS 120 hold on this VM), with their triangle and fight lines; the
art_bench run before the rings became meshes (max 241, sorted_max 210);
the trace cross-check, which agrees in both the culling and the
preprocessing_only mode; and the warm sim suite with the art_bench test
(8.0 to 9.2 s). ui.md points at the rows, and draw_trace_xcheck.sh says
where XCHECK_TARGET_DIR puts the trace binary. ADR 0002's migration
steps and acceptance gain the cross-check, which the script's header
already pointed at: the counter reads render internals Bevy 0.20 may
move.

The art-pass acceptance lines AP1 and AP2 name M7's acceptance lines 3
and 4 instead of their old line numbers, which moved when the section
was added.
tools/inspect_gltf is a new workspace member (members += "tools/*") for
the art pass: inspect, swatches, palette, bake and check over glTF 2.0
files. It reads them with gltf 1.4.1 without its import feature (no
base64 0.13 or urlencoding) and PNGs with png, and has no engine crate,
no glam and no image crate: its matrix, quaternion and normal maths are
hand-written and unit-tested. Cargo.lock gains only the inspect_gltf
package. CI's engine-boundary loop covers it, and DEPENDENCIES.md has
its rows.
tools/assets/sources.tsv lists every third-party file of the art pass
with its raw.githubusercontent.com URL, sha256, licence, author and
per-file page; scripts/fetch_assets.sh (just fetch-assets) checks the
licence texts first (KayKit CC0 1.0, Inter OFL 1.1), then fetches and
verifies the rest into a download cache and copies the vendored files
into assets/. The red KayKit variants and the Adventurers characters stay
in the cache for inspect_gltf swatches and bake (just art-bake;
tools/assets/bake.ron has no rows until art slice 4).

Vendored unmodified, 72 files, 2,985,960 bytes: KayKit Medieval Hexagon's blue
buildings (the windmill too, the Croft's alternative), the neutral
construction stages and rubble, flag and cart props, nature, their
licence text, the Adventurers licence text, and Inter 4.1 with
OFL-Inter.txt, each with its own ATTRIBUTION.md row (one row per
directory is no longer offered). inspect_gltf swatches over the blue and
red variants of every building, the catapult tower and the flag gives one
team rect of the shared atlas, recorded as team_swatches
"kaykit_hexagon"; no Scene names it yet, so nothing draws differently.
ART_STYLE.md records where the files come from and the budgets measured
on them, which inspect_gltf check now applies.
scripts/check_assets.sh --self-test copies only assets/ into a temporary
root, injects models/unattributed.glb and requires exit 1 with that path
on stderr, then exit 0 once it is removed; the allowlist is unchanged.
CI's check job and just ci run it after the asset policy, then
inspect_gltf check over data/visuals.ron and assets/.
The selection ring and the attrition diamond became opaque filled bands
in the same plane (RING_Y). Around each diagonal their bands overlap, so
under a selected unit that bleeds the two meshes z-fought: amber samples
speckled the cream ring and shimmered as the unit moved.

The diamond now sits 5 mm lower, at markers::ATTRITION_Y (0.025 m), so
the ring is always on top. The debug grid's height becomes
ground::GRID_Y, and a compile-time assertion keeps the flat layers in
order: grid, diamonds, rings, the placement ghost's outline. A new test
checks a selected, bleeding unit's ring is above its diamond, and the
existing plane assertions expect each marker's own height.
app::run logged "assets: <dir>" before App::new, when no tracing
subscriber was installed yet, so the line was dropped and no run ever
recorded which asset root the game resolved. It is now logged right
after DefaultPlugins (and with them LogPlugin) are added.

render_smoke.sh now requires the game::app "assets:" line and that the
logged directory holds ATTRIBUTION.md, and prints the root it found.
The selection panel writes one line per unit kind, but only five rows
of 14 px text fit between the bottom bar's padding and the window's
edge. art_bench's select-all (every P0 unit, all eight kinds) pushed
three kind lines off the window in every frame after tick 40, the AP8
art-battle-60m shot included, and any box-select of six or more kinds
did the same.

Past MAX_KIND_LINES (5) kinds the panel now shows the first four in
units.ron order and one "+N more kinds" line (new UI string
info.more_kinds; strings are outside rules_hash). A test selects every
kind, checks the text fits the panel and ends in the summary line, and
that exactly five kinds still get a line each.
grid_is_off_by_default_and_g_toggles_it built its own schedule with a
stand-in counter, so it never ran add_grid_systems or draw_grid: if
draw_grid lost its run_if(grid_shown), the grid would be drawn in every
build while the test stayed green.

The test now adds the real GroundPlugin with the default gizmo group and
reads the lines draw_grid hands to the renderer (the group's GizmoAsset,
or no handle when nothing was drawn): none by default, the grid in the
frame G is pressed and every frame after, none again after a second
press, one toggle for a held key, and the grid from the first frame when
ShowGrid(true) is inserted first, as --grid does.
CONTRIBUTING's data-only walkthrough says a test must not hard-code a
number the data controls. Two new art-pass tests did:

- inspect_gltf's check test expected "suggest scale_x100=95 (1.90 m
  footprint)" and "(fallback height 1.19 m)", worked out by hand from
  the Croft's 2x2 footprint and the Yeoman's radius 35;
- art_bench_is_framed_on_the_town_and_the_fight expected 2 * 174 armed
  units, the ART_BENCH_ARMY rows of the kinds that have an attack today.

Both now compute the expectation from the loaded rules: the Croft's
footprint and the Yeoman's radius, and the start units of armed kinds
plus each side's ART_BENCH_ARMY rows of armed kinds. Today's values are
unchanged (1.90 m, 1.19 m, 348).
ATTRIBUTION.md says Modifications "none" for the 72 files vendored
through tools/assets/sources.tsv, and the attribution test infers that
from the file names, but nothing that runs in CI compared a vendored
file with its pin: only the maintainer-only fetch hashed anything. An
edited model, or an editor trimming the tabs that end lines in KayKit's
licence texts, passed check_assets.sh and every test.

check_assets.sh gains rule 5: when tools/assets/sources.tsv exists under
the root, every row with a dest must exist under assets/ and match its
sha256 (sha256sum, or shasum -a 256 on macOS); a mismatch names the file
and the pin. CI and `just ci` already run the script. --self-test keeps
its assets-only injection check and adds a second root with the
manifest: one changed byte in a pinned file must exit 1 naming it, and
the original bytes must pass.

.editorconfig stops trimming whitespace and adding final newlines under
assets/. ART_STYLE, ATTRIBUTION.md, CONTRIBUTING, the sources.tsv header
and the attribution test's message say the bytes are now checked.
Only one synthetic test reached check_visuals, and its centred boxes on
an 8x8 atlas could never trip most rules: the x/z centre tolerance, the
256-texel rule and its shipped-atlas exemption, the per-file cap,
cameras, lights, skins and animations, non-indexed or NORMAL-less
primitives, two base textures, an unknown team key, a swatch rect
outside the texture, or the rule that only models of one rig are
compared. Dropping or widening any of them left the tests green.

check_flags_centre_texture_file_and_import_rules_and_rig_groups covers
each with its own model: an off-centre composite along x and along z
fails as a building and passes as a prop, the texture limit fails and
the hexagons_medieval.png name is exempt, a 16-byte file cap trips,
camera, light and rigged files are refused, and so on. testkit gains
box_gltf_with to name the box's texture.

The plan (section 2b.4) also has check print the facing it finds. Each
line now carries facing=<side>(<share>%): the horizontal side with the
most outward surface, from each triangle's winding and area, kept in
four separate totals so a closed mesh does not cancel out; props print
"-". check_prints_the_facing_it_finds turns a one-sided quad through
the four yaws and checks a cube ties at 25%.
- "Decision 11" for the mesh rings: the only numbered art-pass decisions
  in the repository are the owner's, whose (11) is the grid and the
  extras. The ring citations in IMPLEMENTER_NOTES, ui.md, BUILD_TIMES,
  ARCHITECTURE, selection.rs and markers.rs now say the art-pass plan's
  decision 11 (rings and diamonds as shared meshes), and the ROADMAP
  Notes explain the two numberings.
- AP9 names a "slice 1 bump" and a <slice-1> base that do not exist on
  this branch: ADR 0003's bump to 25 landed before slice 1. A dated note
  on AP9 and a Notes entry give the base, 15f1616, without rewording the
  line.
- The open M0 [owner] line promises a grid gizmo that has been off by
  default since the art pass; a dated note says G or --grid shows it.
- ART_STYLE said every budget was set from measurements, but the
  team-area floors are the plan's, and six of the eight chosen buildings
  and the Mangonel's catapult fall under them. ART_STYLE now says so,
  lists the measured shares, and describes `inspect_gltf palette` (the
  Inspection row promised it) and check's facing= hint; the ROADMAP
  Notes record the floor choice as pending with the owner before
  slice 3.
- The swatches module doc claimed bit-for-bit geometry; it compares
  positions within POSITION_TOLERANCE and indices exactly.
@tonianev
tonianev merged commit 5423991 into main Oct 28, 2026
5 checks passed
@tonianev
tonianev deleted the claude/confident-hopper-ho6dz1 branch October 28, 2026 01:36
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.

2 participants