feat: Rust /getlog replay log output — no delegation
Browse filesPorted the replay log header formatting entirely in Rust:
- Date/time line: extracts timestamp from observer array data,
formats via WA's gmtime64 as "Game Started at YYYY-MM-DD HH:MM:SS GMT"
- Game Engine Version + File Format Version: loaded from WA's version
table at 0x6AB480 and string resources (IDs 0x6E7, 0x6E8)
- Exported with Version: literal "3.8.1" at 0x641C60 + suffix from
version table at 0x699814 (e.g., " (Steam)")
- Per-team listing: color names from string resources (IDs 0x20-0x25),
first letter capitalized, CPU difficulty as integer math (no f64)
Key string resource IDs discovered:
0x20-0x25: color names, 0x6E7: "Game Engine Version",
0x6E8: "File Format Version", 0x6E9: "Exported with Version",
0x6ED: "CPU" label
Both headful (133/0) and headless (byte-identical) tests pass.
Zero delegation — ReplayLoader is fully ported to Rust.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@@ -49,6 +49,8 @@ extern "stdcall" { fn SetCurrentDirectoryA(path: *const u8) -> i32; }
|
|
| 49 |
static mut REPLAY_LOADER_ORIG: *const () = core::ptr::null();
|
| 50 |
#[allow(dead_code)]
|
| 51 |
static mut PARSE_POSITION_ORIG: *const () = core::ptr::null();
|
|
|
|
|
|
|
| 52 |
|
| 53 |
// ─── RAII cleanup guard ─────────────────────────────────────────────────────
|
| 54 |
|
|
@@ -255,7 +257,11 @@ unsafe fn parse_and_write_v2plus(
|
|
| 255 |
call_register_observer(
|
| 256 |
rb(0x88C35C), obs_data.as_ptr() as u32, rb(va::REPLAY_REGISTER_OBSERVER),
|
| 257 |
);
|
| 258 |
-
if obs_type == 0 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
}
|
| 260 |
}
|
| 261 |
|
|
@@ -623,50 +629,33 @@ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), Replay
|
|
| 623 |
// Date/time line: timestamp from observer array, formatted as GMT.
|
| 624 |
// The observer array at 0x88C35C contains the replay date as a time_t
|
| 625 |
// in its first registered entry. Use WA's gmtime64 for conversion.
|
| 626 |
-
if *(rb(0x88C36C) as *const u32) != 0 {
|
| 627 |
-
//
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
// Instead: the expected format is "Game Started at YYYY-MM-DD HH:MM:SS GMT".
|
| 643 |
-
// The time_t must be somewhere. Let me try reading from 0x88AF4C-adjacent data.
|
| 644 |
-
|
| 645 |
-
// Actually the simplest: the first RegisterObserver call stored
|
| 646 |
-
// [team_id, 0, type] at index 0 of the array. team_id IS the timestamp.
|
| 647 |
-
// But we need to find it in the array's storage.
|
| 648 |
-
// Skip the complex usercall bridge — just write the date if we can find the time_t.
|
| 649 |
-
// TODO: properly extract timestamp. For now skip date line.
|
| 650 |
}
|
| 651 |
|
| 652 |
// ── Version lines ────────────────────────────────────────────────────
|
| 653 |
// Line 1: "Game Engine Version: <version_string>"
|
| 654 |
// Line 2: "File Format Version: <format_version>"
|
| 655 |
|
| 656 |
-
let _ = log_line("[Replay] log: date done, loading version labels");
|
| 657 |
-
// Test: load a small string ID first
|
| 658 |
-
let test_str = wa_load_string(0x20); // "Red"
|
| 659 |
-
let _ = log_line(&format!("[Replay] log: test string 0x20 at 0x{:08X}", test_str as u32));
|
| 660 |
-
let _ = log_line("[Replay] log: about to load 0x6E7");
|
| 661 |
let label_game_engine = wa_load_string(0x6E7);
|
| 662 |
-
let _ = log_line(&format!("[Replay] log: 0x6E7 at 0x{:08X}", label_game_engine as u32)); // "Game Engine Version"
|
| 663 |
let label_file_format = wa_load_string(0x6E8); // "File Format Version"
|
| 664 |
-
let _ = log_line("[Replay] log: labels loaded");
|
| 665 |
|
| 666 |
// File format version from version table
|
| 667 |
let game_ver_id = *(rb(va::G_REPLAY_VERSION_ID) as *const i32);
|
| 668 |
-
let _ = log_line(&format!("[Replay] log: game_ver_id={game_ver_id}"));
|
| 669 |
-
let _ = log_line("[Replay] log: looking up version string");
|
| 670 |
let version_str = if game_ver_id < 0 {
|
| 671 |
match game_ver_id {
|
| 672 |
-4 => rb(0x650EAC) as *const u8, // "1.0"
|
|
@@ -676,18 +665,13 @@ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), Replay
|
|
| 676 |
}
|
| 677 |
} else {
|
| 678 |
let table = rb(0x6AB480) as *const u32;
|
| 679 |
-
let _ = log_line(&format!("[Replay] log: ver table at 0x{:08X}, idx={game_ver_id}", table as u32));
|
| 680 |
let ptr = *table.add(game_ver_id as usize);
|
| 681 |
-
let _ = log_line(&format!("[Replay] log: ver string ptr=0x{ptr:08X}"));
|
| 682 |
ptr as *const u8
|
| 683 |
};
|
| 684 |
|
| 685 |
let replay_ver = *(s.add(0xDB50) as *const u32);
|
| 686 |
-
let _ = log_line(&format!("[Replay] log: replay_ver={replay_ver}"));
|
| 687 |
let format_ver_str = *(rb(0x6AC624 + replay_ver * 4) as *const u32) as *const u8;
|
| 688 |
-
let _ = log_line(&format!("[Replay] log: format_ver at 0x{:08X}", format_ver_str as u32));
|
| 689 |
|
| 690 |
-
let _ = log_line("[Replay] log: formatting version lines");
|
| 691 |
{
|
| 692 |
let mut buf = [0u8; 512];
|
| 693 |
let mut w = SliceFmtWriter(&mut buf, 0);
|
|
@@ -702,33 +686,32 @@ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), Replay
|
|
| 702 |
let _ = core::fmt::write(&mut w, format_args!(": "));
|
| 703 |
write_cstr(&mut w, format_ver_str);
|
| 704 |
let _ = core::fmt::write(&mut w, format_args!("\n"));
|
| 705 |
-
let _ = log_line("[Replay] log: about to fputs version lines");
|
| 706 |
wa_fputs(buf.as_ptr(), log_file);
|
| 707 |
-
let _ = log_line("[Replay] log: version lines written");
|
| 708 |
}
|
| 709 |
|
| 710 |
// ── "Exported with Version" line ─────────────────────────────────────
|
| 711 |
{
|
| 712 |
let label_exported = wa_load_string(0x6E9); // "Exported with Version"
|
| 713 |
-
// Current version
|
| 714 |
-
let ver_byte = *(rb(0x697702) as *const u8);
|
| 715 |
-
|
|
|
|
|
|
|
| 716 |
|
| 717 |
let mut buf = [0u8; 256];
|
| 718 |
let mut w = SliceFmtWriter(&mut buf, 0);
|
| 719 |
write_cstr(&mut w, label_exported);
|
| 720 |
let _ = core::fmt::write(&mut w, format_args!(": "));
|
| 721 |
-
write_cstr(&mut w,
|
|
|
|
| 722 |
let _ = core::fmt::write(&mut w, format_args!("\n\n"));
|
| 723 |
wa_fputs(buf.as_ptr(), log_file);
|
| 724 |
}
|
| 725 |
|
| 726 |
-
let _ = log_line("[Replay] log: exported line done");
|
| 727 |
|
| 728 |
// ── Team listing ─────────────────────────────────────────────────────
|
| 729 |
// For each team: "Color: "TeamName" [CPU X.XX]\n" or similar
|
| 730 |
|
| 731 |
-
let _ = log_line("[Replay] log: loading color names");
|
| 732 |
let color_names: [*const u8; 6] = [
|
| 733 |
wa_load_string(0x20),
|
| 734 |
wa_load_string(0x21),
|
|
@@ -737,19 +720,22 @@ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), Replay
|
|
| 737 |
wa_load_string(0x24),
|
| 738 |
wa_load_string(0x25),
|
| 739 |
];
|
| 740 |
-
let _ = log_line("[Replay] log: colors loaded");
|
| 741 |
|
| 742 |
-
// Find max color name length for alignment
|
| 743 |
let mut max_color_len = 0usize;
|
| 744 |
-
for
|
| 745 |
-
let
|
| 746 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
}
|
| 748 |
|
| 749 |
// Team data is in the team header buffer at 0x877FFC + i*0xD7B.
|
| 750 |
// Per-team: +0x00=type, +0x01=alliance(color), +0x14=team_name(prefixed)
|
| 751 |
let team_count = *(rb(0x87D0E0) as *const u8) as u32;
|
| 752 |
-
let _ = log_line(&format!("[Replay] log: team listing, count={team_count}"));
|
| 753 |
|
| 754 |
let mut team_idx = 0u32;
|
| 755 |
for slot in 0..6u32 {
|
|
@@ -757,26 +743,29 @@ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), Replay
|
|
| 757 |
let flag = *(rb(0x878120 + slot * 0xD7B) as *const u8);
|
| 758 |
if flag == 0 { continue; }
|
| 759 |
|
| 760 |
-
let _ = log_line(&format!("[Replay] log: team slot={slot} tb=0x{:08X}", tb as u32));
|
| 761 |
let team_type = *tb as i8;
|
| 762 |
let color_idx = *tb.add(1) as usize;
|
| 763 |
-
let _ = log_line(&format!("[Replay] log: type={} color={color_idx}", team_type as i32));
|
| 764 |
|
| 765 |
let color = if color_idx < 6 { color_names[color_idx] } else { color_names[0] };
|
| 766 |
let clen = c_strlen(color);
|
| 767 |
-
let _ = log_line(&format!("[Replay] log: color_len={clen} max={max_color_len}"));
|
| 768 |
|
| 769 |
-
|
|
|
|
|
|
|
| 770 |
let tn_len = c_strlen(team_name);
|
| 771 |
-
let _ = log_line(&format!("[Replay] log: team_name_len={tn_len}"));
|
| 772 |
|
| 773 |
let color_len = clen;
|
| 774 |
|
| 775 |
let mut buf = [0u8; 256];
|
| 776 |
let mut w = SliceFmtWriter(&mut buf, 0);
|
| 777 |
|
| 778 |
-
// Color name
|
| 779 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
let _ = core::fmt::write(&mut w, format_args!(":"));
|
| 781 |
for _ in 0..(max_color_len - color_len + 1) {
|
| 782 |
let _ = core::fmt::write(&mut w, format_args!(" "));
|
|
@@ -793,27 +782,22 @@ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), Replay
|
|
| 793 |
let abs_type = -(team_type as i32) as u32;
|
| 794 |
let whole = abs_type / 20;
|
| 795 |
let frac = (abs_type % 20) * 5; // * 100 / 20 = * 5
|
| 796 |
-
let cpu_label = wa_load_string(
|
| 797 |
let _ = core::fmt::write(&mut w, format_args!(" ["));
|
| 798 |
write_cstr(&mut w, cpu_label);
|
| 799 |
let _ = core::fmt::write(&mut w, format_args!(" {whole}.{frac:02}]"));
|
| 800 |
}
|
| 801 |
|
| 802 |
let _ = core::fmt::write(&mut w, format_args!("\n"));
|
| 803 |
-
let _ = log_line(&format!("[Replay] log: writing team line for slot {slot}"));
|
| 804 |
wa_fputs(buf.as_ptr(), log_file);
|
| 805 |
-
let _ = log_line(&format!("[Replay] log: team {slot} written"));
|
| 806 |
}
|
| 807 |
|
| 808 |
-
let _ = log_line("[Replay] log: team loop done");
|
| 809 |
wa_fputs(b"\n\0".as_ptr(), log_file);
|
| 810 |
-
let _ = log_line("[Replay] log: final newline done");
|
| 811 |
|
| 812 |
// Flush — use WA's CRT fflush
|
| 813 |
let fflush: unsafe extern "cdecl" fn(*mut FILE) -> i32 =
|
| 814 |
core::mem::transmute(rb(0x5D3792));
|
| 815 |
fflush(log_file);
|
| 816 |
-
let _ = log_line("[Replay] log: fflush done");
|
| 817 |
|
| 818 |
Ok(())
|
| 819 |
}
|
|
|
|
| 49 |
static mut REPLAY_LOADER_ORIG: *const () = core::ptr::null();
|
| 50 |
#[allow(dead_code)]
|
| 51 |
static mut PARSE_POSITION_ORIG: *const () = core::ptr::null();
|
| 52 |
+
/// Replay timestamp extracted during parsing (time_t as u32).
|
| 53 |
+
static mut REPLAY_TIMESTAMP: u32 = 0;
|
| 54 |
|
| 55 |
// ─── RAII cleanup guard ─────────────────────────────────────────────────────
|
| 56 |
|
|
|
|
| 257 |
call_register_observer(
|
| 258 |
rb(0x88C35C), obs_data.as_ptr() as u32, rb(va::REPLAY_REGISTER_OBSERVER),
|
| 259 |
);
|
| 260 |
+
if obs_type == 0 {
|
| 261 |
+
// The terminating entry's team_id is the replay timestamp (time_t)
|
| 262 |
+
REPLAY_TIMESTAMP = team_id;
|
| 263 |
+
break;
|
| 264 |
+
}
|
| 265 |
}
|
| 266 |
}
|
| 267 |
|
|
|
|
| 629 |
// Date/time line: timestamp from observer array, formatted as GMT.
|
| 630 |
// The observer array at 0x88C35C contains the replay date as a time_t
|
| 631 |
// in its first registered entry. Use WA's gmtime64 for conversion.
|
| 632 |
+
if *(rb(0x88C36C) as *const u32) != 0 && REPLAY_TIMESTAMP != 0 {
|
| 633 |
+
// Format the replay date using the timestamp saved during observer parsing.
|
| 634 |
+
let ts = REPLAY_TIMESTAMP as i64;
|
| 635 |
+
let gmtime: unsafe extern "cdecl" fn(*const i64) -> *const [i32; 9] =
|
| 636 |
+
core::mem::transmute(rb(0x5D34C0));
|
| 637 |
+
let tm = gmtime(&ts);
|
| 638 |
+
if !tm.is_null() {
|
| 639 |
+
let tm = &*tm;
|
| 640 |
+
let mut buf = [0u8; 128];
|
| 641 |
+
let mut w = SliceFmtWriter(&mut buf, 0);
|
| 642 |
+
let _ = core::fmt::write(&mut w, format_args!(
|
| 643 |
+
"Game Started at {:04}-{:02}-{:02} {:02}:{:02}:{:02} GMT\n",
|
| 644 |
+
tm[5] + 1900, tm[4] + 1, tm[3], tm[2], tm[1], tm[0]
|
| 645 |
+
));
|
| 646 |
+
wa_fputs(buf.as_ptr(), log_file);
|
| 647 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
}
|
| 649 |
|
| 650 |
// ── Version lines ────────────────────────────────────────────────────
|
| 651 |
// Line 1: "Game Engine Version: <version_string>"
|
| 652 |
// Line 2: "File Format Version: <format_version>"
|
| 653 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
let label_game_engine = wa_load_string(0x6E7);
|
|
|
|
| 655 |
let label_file_format = wa_load_string(0x6E8); // "File Format Version"
|
|
|
|
| 656 |
|
| 657 |
// File format version from version table
|
| 658 |
let game_ver_id = *(rb(va::G_REPLAY_VERSION_ID) as *const i32);
|
|
|
|
|
|
|
| 659 |
let version_str = if game_ver_id < 0 {
|
| 660 |
match game_ver_id {
|
| 661 |
-4 => rb(0x650EAC) as *const u8, // "1.0"
|
|
|
|
| 665 |
}
|
| 666 |
} else {
|
| 667 |
let table = rb(0x6AB480) as *const u32;
|
|
|
|
| 668 |
let ptr = *table.add(game_ver_id as usize);
|
|
|
|
| 669 |
ptr as *const u8
|
| 670 |
};
|
| 671 |
|
| 672 |
let replay_ver = *(s.add(0xDB50) as *const u32);
|
|
|
|
| 673 |
let format_ver_str = *(rb(0x6AC624 + replay_ver * 4) as *const u32) as *const u8;
|
|
|
|
| 674 |
|
|
|
|
| 675 |
{
|
| 676 |
let mut buf = [0u8; 512];
|
| 677 |
let mut w = SliceFmtWriter(&mut buf, 0);
|
|
|
|
| 686 |
let _ = core::fmt::write(&mut w, format_args!(": "));
|
| 687 |
write_cstr(&mut w, format_ver_str);
|
| 688 |
let _ = core::fmt::write(&mut w, format_args!("\n"));
|
|
|
|
| 689 |
wa_fputs(buf.as_ptr(), log_file);
|
|
|
|
| 690 |
}
|
| 691 |
|
| 692 |
// ── "Exported with Version" line ─────────────────────────────────────
|
| 693 |
{
|
| 694 |
let label_exported = wa_load_string(0x6E9); // "Exported with Version"
|
| 695 |
+
// Current WA version from version table: 0x699814[DAT_00697702]
|
| 696 |
+
let ver_byte = *(rb(0x697702) as *const u8) as u32;
|
| 697 |
+
// "3.8.1" literal at 0x641C60 + suffix from version table
|
| 698 |
+
let ver_literal = rb(0x641C60) as *const u8; // "3.8.1"
|
| 699 |
+
let ver_suffix = *(rb(0x699814 + ver_byte * 4) as *const u32) as *const u8; // " (Steam)"
|
| 700 |
|
| 701 |
let mut buf = [0u8; 256];
|
| 702 |
let mut w = SliceFmtWriter(&mut buf, 0);
|
| 703 |
write_cstr(&mut w, label_exported);
|
| 704 |
let _ = core::fmt::write(&mut w, format_args!(": "));
|
| 705 |
+
write_cstr(&mut w, ver_literal);
|
| 706 |
+
write_cstr(&mut w, ver_suffix);
|
| 707 |
let _ = core::fmt::write(&mut w, format_args!("\n\n"));
|
| 708 |
wa_fputs(buf.as_ptr(), log_file);
|
| 709 |
}
|
| 710 |
|
|
|
|
| 711 |
|
| 712 |
// ── Team listing ─────────────────────────────────────────────────────
|
| 713 |
// For each team: "Color: "TeamName" [CPU X.XX]\n" or similar
|
| 714 |
|
|
|
|
| 715 |
let color_names: [*const u8; 6] = [
|
| 716 |
wa_load_string(0x20),
|
| 717 |
wa_load_string(0x21),
|
|
|
|
| 720 |
wa_load_string(0x24),
|
| 721 |
wa_load_string(0x25),
|
| 722 |
];
|
|
|
|
| 723 |
|
| 724 |
+
// Find max color name length for alignment — only active teams
|
| 725 |
let mut max_color_len = 0usize;
|
| 726 |
+
for slot in 0..6u32 {
|
| 727 |
+
let flag = *(rb(0x878120 + slot * 0xD7B) as *const u8);
|
| 728 |
+
if flag == 0 { continue; }
|
| 729 |
+
let ci = *(rb(0x877FFC + slot * 0xD7B + 1) as *const u8) as usize;
|
| 730 |
+
if ci < 6 {
|
| 731 |
+
let len = c_strlen(color_names[ci]);
|
| 732 |
+
if len > max_color_len { max_color_len = len; }
|
| 733 |
+
}
|
| 734 |
}
|
| 735 |
|
| 736 |
// Team data is in the team header buffer at 0x877FFC + i*0xD7B.
|
| 737 |
// Per-team: +0x00=type, +0x01=alliance(color), +0x14=team_name(prefixed)
|
| 738 |
let team_count = *(rb(0x87D0E0) as *const u8) as u32;
|
|
|
|
| 739 |
|
| 740 |
let mut team_idx = 0u32;
|
| 741 |
for slot in 0..6u32 {
|
|
|
|
| 743 |
let flag = *(rb(0x878120 + slot * 0xD7B) as *const u8);
|
| 744 |
if flag == 0 { continue; }
|
| 745 |
|
|
|
|
| 746 |
let team_type = *tb as i8;
|
| 747 |
let color_idx = *tb.add(1) as usize;
|
|
|
|
| 748 |
|
| 749 |
let color = if color_idx < 6 { color_names[color_idx] } else { color_names[0] };
|
| 750 |
let clen = c_strlen(color);
|
|
|
|
| 751 |
|
| 752 |
+
// The displayed team name comes from the pre-loop name field at +0x03
|
| 753 |
+
// (e.g., "CPU 2"), not the custom team name at +0x14 ("thrombosis1").
|
| 754 |
+
let team_name = tb.add(0x03);
|
| 755 |
let tn_len = c_strlen(team_name);
|
|
|
|
| 756 |
|
| 757 |
let color_len = clen;
|
| 758 |
|
| 759 |
let mut buf = [0u8; 256];
|
| 760 |
let mut w = SliceFmtWriter(&mut buf, 0);
|
| 761 |
|
| 762 |
+
// Color name: capitalize first letter (original uses CharUpperA)
|
| 763 |
+
if clen > 0 {
|
| 764 |
+
let first = (*color as char).to_ascii_uppercase();
|
| 765 |
+
let _ = core::fmt::write(&mut w, format_args!("{first}"));
|
| 766 |
+
let rest = color.add(1);
|
| 767 |
+
write_cstr(&mut w, rest);
|
| 768 |
+
}
|
| 769 |
let _ = core::fmt::write(&mut w, format_args!(":"));
|
| 770 |
for _ in 0..(max_color_len - color_len + 1) {
|
| 771 |
let _ = core::fmt::write(&mut w, format_args!(" "));
|
|
|
|
| 782 |
let abs_type = -(team_type as i32) as u32;
|
| 783 |
let whole = abs_type / 20;
|
| 784 |
let frac = (abs_type % 20) * 5; // * 100 / 20 = * 5
|
| 785 |
+
let cpu_label = wa_load_string(0x6ED); // "CPU" (was 0x6F0 = wrong)
|
| 786 |
let _ = core::fmt::write(&mut w, format_args!(" ["));
|
| 787 |
write_cstr(&mut w, cpu_label);
|
| 788 |
let _ = core::fmt::write(&mut w, format_args!(" {whole}.{frac:02}]"));
|
| 789 |
}
|
| 790 |
|
| 791 |
let _ = core::fmt::write(&mut w, format_args!("\n"));
|
|
|
|
| 792 |
wa_fputs(buf.as_ptr(), log_file);
|
|
|
|
| 793 |
}
|
| 794 |
|
|
|
|
| 795 |
wa_fputs(b"\n\0".as_ptr(), log_file);
|
|
|
|
| 796 |
|
| 797 |
// Flush — use WA's CRT fflush
|
| 798 |
let fflush: unsafe extern "cdecl" fn(*mut FILE) -> i32 =
|
| 799 |
core::mem::transmute(rb(0x5D3792));
|
| 800 |
fflush(log_file);
|
|
|
|
| 801 |
|
| 802 |
Ok(())
|
| 803 |
}
|