Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,36 +21,43 @@ jobs:
- task: lint-imports
use_uv: true
do_sync: true
use_zig: false
command: uv run --no-sync lint-imports
sync_args: --group dev
- task: docs-check
use_uv: true
do_sync: false
use_zig: false
command: uv run --no-sync python scripts/check_docs.py
sync_args: ""
- task: ast-grep
use_uv: false
do_sync: false
use_zig: false
command: sg scan && sg test && bash tools/ast-grep/scripts/build-zig-parser.sh tools/ast-grep/parsers/zig.so && sg scan -c sgconfig.ci.yml && sg test -c sgconfig.ci.yml
sync_args: ""
- task: ty
use_uv: true
do_sync: true
use_zig: false
command: uv run --no-sync ty check src
sync_args: --group dev
- task: ty-tests
use_uv: true
do_sync: true
use_zig: false
command: uv run --no-sync ty check tests
sync_args: --group dev
- task: pytest
use_uv: true
do_sync: true
use_zig: true
command: uv run --no-sync pytest --no-cov
sync_args: --group dev
- task: build
use_uv: true
do_sync: false
use_zig: false
command: uv build
sync_args: ""

Expand All @@ -73,6 +80,12 @@ jobs:
mkdir -p "$UV_CACHE_DIR"
fi

- name: Setup Zig
if: matrix.use_zig
uses: mlugg/setup-zig@v2
with:
version: "0.15.2"

- name: Setup Node
if: ${{ matrix.task == 'ast-grep' }}
uses: actions/setup-node@v4
Expand Down
20 changes: 12 additions & 8 deletions crimson-zig/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# crimson-zig

Standalone Zig replay verifier workspace.
Standalone Zig workspace for the native Crimson port.

## Scope (current)

- Native CLI surface: `crimson-zig replay verify ...`
- JSON output contract: mirrors `crimson replay verify --format json`
- WASM target: `wasm32-freestanding` export ABI for Worker-style hosts
- Direction: full native port of Crimson runtime and supporting codecs/tooling.
- Current primary shipped CLI surface: `crimson-zig replay verify ...`
- Current JSON output contract: mirrors `crimson replay verify --format json`
- Current WASM target: `wasm32-freestanding` export ABI for Worker-style hosts

## Format codecs (library-only)

Expand All @@ -23,18 +24,21 @@ This wave is intentionally codec-only:
- no `ensure_*`/repair helpers,
- no JPEG-to-RGBA expansion yet (JAZ returns split payload components).

## Current backend behavior
## Current native state

- Native CLI currently verifies **latest-ruleset single-player survival/rush** replays using:
- `crimson-zig` is no longer verifier-only in project direction.
- Replay verification is the most complete user-facing entrypoint today.
- Runtime, codecs, and window/bootstrap targets are being ported as pieces of a full native implementation.
- Native CLI currently verifies **1-4 player survival/rush/quest** replays, including preserve-bugs compatibility mode, using:
- replay msgpack+gzip decoding in Zig (via `msgpack.zig`, full header/inputs/events model),
- native deterministic simulation pass in Zig (canonical event ordering + input/event counters),
- canonical terrain bootstrap RNG validation,
- full deterministic run-result generation on supported native paths.
- Native verifier now intentionally **does not** read replay sidecars (`.crd.chk`) or highscores (`scores5/survival.hi`); replay-only inputs are the source of truth.
- CLI hard-fails for unsupported/unported native paths (quests, multiplayer, preserve-bugs, non-latest ruleset, or unsupported option/event shapes).
- CLI still hard-fails for unsupported or unported native paths instead of falling back.
- WASM exports keep ABI shape but currently hard-fail verification with a `not yet ported` error.

This gives immediate CLI/ABI parity scaffolding while deeper gameplay porting proceeds.
The verifier remains a useful parity harness, but it is now a consumer of the broader Zig port rather than the whole point of the workspace.

## Build

Expand Down
92 changes: 29 additions & 63 deletions crimson-zig/src/cdt_trace.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ const replay_codec = @import("replay_codec.zig");
const replay_runner = @import("runtime/replay_runner.zig");
const state_mod = @import("runtime/state.zig");

const weapon_usage_count = replay_codec.weapon_usage_count;
const crt_rand_mult: u32 = 214_013;
const crt_rand_inc: u32 = 2_531_011;
const max_rng_draws_per_phase: usize = 1_000_000;

const trace_magic = "crimson_debug_trace_v1\n";
const trace_format_version: u32 = 1;
const trace_schema_version: i32 = 7;
const trace_schema_version: i32 = 10;

const chunk_kind_meta = "META";
const chunk_kind_tick = "TICK";
Expand Down Expand Up @@ -122,6 +121,7 @@ const TraceMeta = struct {
channel_versions: ChannelVersions = .{},
tick_range: TickRange,
config: TraceConfig,
status: replay_codec.ReplayStatus = .{},
};

const TickBlockIndexEntry = struct {
Expand Down Expand Up @@ -174,12 +174,6 @@ const SnapshotPlayer = struct {
level: i32,
};

const SnapshotStatus = struct {
quest_unlock_index: i32 = 0,
quest_unlock_index_full: i32 = 0,
weapon_usage_counts: [weapon_usage_count]i32,
};

const SnapshotBonusTimers = struct {
weapon_power_up_ms: i32,
reflex_boost_ms: i32,
Expand All @@ -195,21 +189,19 @@ const SnapshotGameplay = struct {
perk_pending_count: i32,
perk_choices_dirty: bool = false,
bonus_timers: SnapshotBonusTimers,
status: SnapshotStatus,
};

const SimStateSnapshot = struct {
gameplay: SnapshotGameplay,
players: [1]SnapshotPlayer,
players: []const SnapshotPlayer,
};

const RngStreamRow = struct {
tick_call_index: i32,
value_15: i32,
state_before_u32: i64,
state_after_u32: i64,
caller_static: ?[]const u8 = null,
branch_id: ?[]const u8 = null,
caller: ?i32 = null,
};

const TimingSampleRow = struct {
Expand Down Expand Up @@ -493,6 +485,7 @@ pub fn writeReplayTickTraceCdt(
.zig_exit_code = options.verify_exit_code,
.zig_stderr_present = options.verify_stderr_present,
},
.status = replay.header.status,
};

try out.writeAll(trace_magic);
Expand Down Expand Up @@ -534,11 +527,6 @@ pub fn writeReplayTickTraceCdt(
const chunk_ticks = if (options.chunk_ticks == 0) 1 else options.chunk_ticks;
var elapsed_ms_accum: i64 = 0;
var tick_rng_start_state: u32 = replay.header.seed;
const status_usage_offset_weapon_id: ?game_ids.WeaponId =
if (replay.header.game_mode_id == @intFromEnum(game_ids.GameModeId.quests))
rows[0].player_state.weapon.weapon_id
else
null;

var last_tick_seen: ?i32 = null;
for (rows) |row| {
Expand All @@ -552,7 +540,6 @@ pub fn writeReplayTickTraceCdt(
dt_ms_i32,
elapsed_ms_accum,
tick_rng_start_state,
status_usage_offset_weapon_id,
&creature_state,
&projectile_state,
&secondary_state,
Expand Down Expand Up @@ -683,7 +670,6 @@ fn buildTickRecord(
dt_ms_i32: i32,
elapsed_ms: i64,
tick_rng_start_state: u32,
status_usage_offset_weapon_id: ?game_ids.WeaponId,
creature_state: *EntityGenerationState,
projectile_state: *EntityGenerationState,
secondary_state: *EntityGenerationState,
Expand All @@ -694,11 +680,12 @@ fn buildTickRecord(
errdefer if (rng_stream.len > 0) allocator.free(rng_stream);
const checkpoint = try buildCheckpoint(allocator, row, elapsed_ms);
errdefer deinitCheckpoint(allocator, &checkpoint);
const sim_state = buildSimState(
const sim_state = try buildSimState(
allocator,
row,
replay.header.game_mode_id,
status_usage_offset_weapon_id,
);
errdefer allocator.free(sim_state.players);
const entity_samples = try buildEntitySamples(
allocator,
row,
Expand All @@ -724,6 +711,7 @@ fn buildTickRecord(

fn deinitTickRecord(allocator: std.mem.Allocator, record: *TickRecord) void {
deinitCheckpoint(allocator, &record.channels.checkpoint);
allocator.free(record.channels.sim_state.players);
allocator.free(record.channels.entity_samples.creatures);
allocator.free(record.channels.entity_samples.projectiles);
allocator.free(record.channels.entity_samples.secondary_projectiles);
Expand Down Expand Up @@ -847,19 +835,28 @@ fn buildEntitySamples(
}

fn buildSimState(
allocator: std.mem.Allocator,
row: replay_runner.ReplayTickTrace,
mode_id: i32,
status_usage_offset_weapon_id: ?game_ids.WeaponId,
) SimStateSnapshot {
) TraceWriteError!SimStateSnapshot {
const player = row.player_state;
var usage_counts = gameplayStatusUsageCounts(row.gameplay_state.status_weapon_usage_counts);
if (status_usage_offset_weapon_id) |weapon_id| {
// Quest bootstrap in Python status applies one initial loadout usage increment.
const weapon_idx: usize = @intCast(@max(0, @intFromEnum(weapon_id)));
if (weapon_idx < usage_counts.len) {
usage_counts[weapon_idx] = usage_counts[weapon_idx] + 1;
}
}
const players = try allocator.alloc(SnapshotPlayer, 1);
players[0] = .{
.index = player.index,
.pos = .{ .x = player.pos.x, .y = player.pos.y },
.health = player.health,
.weapon = .{
.weapon_id = @intFromEnum(player.weapon.weapon_id),
.ammo = player.weapon.ammo,
.clip_size = player.weapon.clip_size,
.reload_active = player.weapon.reload_active,
.reload_timer = player.weapon.reload_timer,
.reload_timer_max = player.weapon.reload_timer_max,
.shot_cooldown = player.weapon.shot_cooldown,
},
.experience = player.experience,
.level = player.level,
};
return .{
.gameplay = .{
.mode_id = mode_id,
Expand All @@ -874,30 +871,8 @@ fn buildSimState(
.double_experience_ms = bonusTimerMs(row.gameplay_state.bonuses.double_experience),
.freeze_ms = bonusTimerMs(row.gameplay_state.bonuses.freeze),
},
.status = .{
.quest_unlock_index = row.gameplay_state.status_quest_unlock_index,
.quest_unlock_index_full = row.gameplay_state.status_quest_unlock_index_full,
.weapon_usage_counts = usage_counts,
},
},
.players = .{
.{
.index = player.index,
.pos = .{ .x = player.pos.x, .y = player.pos.y },
.health = player.health,
.weapon = .{
.weapon_id = @intFromEnum(player.weapon.weapon_id),
.ammo = player.weapon.ammo,
.clip_size = player.weapon.clip_size,
.reload_active = player.weapon.reload_active,
.reload_timer = player.weapon.reload_timer,
.reload_timer_max = player.weapon.reload_timer_max,
.shot_cooldown = player.weapon.shot_cooldown,
},
.experience = player.experience,
.level = player.level,
},
},
.players = players,
};
}

Expand Down Expand Up @@ -971,15 +946,6 @@ fn deinitCheckpoint(allocator: std.mem.Allocator, checkpoint: *const CheckpointC
}
}

fn gameplayStatusUsageCounts(raw: state_mod.WeaponUsageCounts) [weapon_usage_count]i32 {
var out: [weapon_usage_count]i32 = [_]i32{0} ** weapon_usage_count;
for (0..weapon_usage_count) |idx| {
const weapon_id: game_ids.WeaponId = @enumFromInt(idx);
out[idx] = @intCast(raw.get(weapon_id));
}
return out;
}

fn buildPerkChoices(
allocator: std.mem.Allocator,
choices: [7]game_ids.PerkId,
Expand Down
1 change: 1 addition & 0 deletions crimson-zig/src/game_ids.zig
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub const ProjectileTypeId = enum(i32) {
ion_cannon = 0x17,
shrinkifier = 0x18,
blade_gun = 0x19,
spider_plasma = 0x1A,
plasma_cannon = 0x1C,
splitter_gun = 0x1D,
plague_spreader = 0x29,
Expand Down
Loading
Loading