-
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathplayer_state.cpp
More file actions
1633 lines (1545 loc) · 82.7 KB
/
Copy pathplayer_state.cpp
File metadata and controls
1633 lines (1545 loc) · 82.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SacredSDK — read-only player-state inspector.
//
// All addresses + offsets are taken verbatim from the public Cheat Engine
// table "Sacred (Public) 1.0.CT" and verified against the version-2006-10-13
// Steam build. Image base is fixed at 0x00400000 (Sacred.exe has no ASLR),
// so RVAs are stable across runs.
//
// Pointer chain semantics — CE's offset list is applied bottom-up. For an
// "Address" of `Sacred.exe+RVA` and offsets [A, B, C, D]:
//
// p = *(uintptr_t*)(image_base + RVA)
// p = *(uintptr_t*)(p + D) // last offset, applied first
// p = *(uintptr_t*)(p + C)
// p = *(uintptr_t*)(p + B)
// value = *(T*)(p + A) // top offset is the final read, NOT a deref
//
// All reads are guarded by IsBadReadPtr (slow but safe) so a torn chain
// during loading screens doesn't crash us — we just report `valid=false`.
#include <string.h>
#include "sdk.h"
#include "engine/addresses.h" // Goal A1: centralized engine VAs (engine::addr::*)
#include "engine/offsets.h" // Goal A1: centralized struct offsets (engine::off::*)
#include "engine/mem.h" // Goal A1: SEH-safe accessors (engine::mem::*)
#include "engine/singletons.h" // Goal A3: qm()/om()/ctx() canonical accessors
#include "engine/player_internal.h" // Goal A4: shared safe_* accessors for the split
#include "ports/engine/sacred_hash.h" // resource-name hash (dialog-text globalres probe)
#include <stdint.h>
namespace sdk { namespace player {
// --- Hero snapshot / world_pos / name tables MOVED to engine/hero.cpp (A4).
// class_name, skill_name, CLASS_NAMES, SKILL_NAMES, resolve_player_struct,
// hero_base, world_pos now live there (same sdk::player namespace). ---
// Teleport the active hero to KompassPos (kx, ky) — the SAME space F7
// dumps and questbook_set_kompass/marker take.
//
// This calls the ENGINE's own teleport, FUN_0054d9d0 (the function the
// Teleport/DirectTeleport FunkCode handlers use). It is __thiscall with
// ECX = the hero cCreature*, args (tileX, tileY, level, flag). Unlike a
// raw +0x1C/+0x20 write (which desynced the streaming sector → fade to
// black), this does the full move: collision/placement check, sector
// switch, trigger + follower fixup. Coordinates are tile / KompassPos
// units (proven by the engine's own 0x189c/0x188f endgame constants and
// FUN_006224b0 tile→world conversion inside it). level 0 = same world.
// Returns false if the hero chain isn't resolved yet OR the engine
// rejected the placement (returns 0 — non-destructive). Retry next tick.
typedef int(__thiscall* fn_engine_tp)(void* self, int x, int y, int level,
int flag);
// Spawn a creature/NPC at KompassPos (kx,ky) via the engine's OWN create
// path — the exact recipe FUN_0054d9d0 uses:
// pos = FUN_006224b0(this=&buf, 0, X, Y, 0) build a position struct
// handle = cObjectManager::create_005fba40(this=om, type, &buf, 0,1,0)
// `type` = creature class id (npc.lua, 1..714). Coords are KompassPos —
// the SAME space the working hero teleport used. Returns the new creature
// handle (>0) or 0 on failure. __thiscall fn-ptrs (ECX = this).
typedef void (__thiscall* fn_pos_build)(void* self, uint16_t a,
uint32_t x, uint32_t y, uint8_t d);
typedef int (__thiscall* fn_obj_create)(void* self, uint32_t type,
void* pos, uint32_t p3,
char p4, uint32_t p5);
// FUN_00635c40: __thiscall(ECX = cWorld, ushort* pos) — fills pos->sector
// from cWorld+0x284 grid[(X>>6)*0x80+(Y>>6)]; returns 0 if off-map.
typedef char (__thiscall* fn_sector_resolve)(void* world, void* pos);
// Spawn `type` RIGHT AT THE HERO — no sector resolution needed: the hero
// is, by definition, standing in a valid (sector,X,Y,level). We copy the
// hero cCreature's own position bytes VERBATIM into the pos struct (the
// exact encoding the engine itself uses for a placed creature: +0x18 u16
// sector, +0x1C/+0x20 X/Y, +0x24 u8 level) and create() with that. This
// sidesteps the parked-at-default failure of coordinate spawns until the
// cWorld sector-map singleton is found. Returns handle or 0.
int spawn_npc_here(int type) {
HMODULE exe = g_attach.exe_module;
if (!exe) return 0;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t om = 0, ctx = 0, arr = 0, arr_end = 0, hero = 0;
uint32_t idx = 0;
if (!safe_read_ptr(reb + 0x00AD5C40, &om) || !om) return 0;
if (!safe_read_ptr(reb + 0x0182EBE8, &ctx) || !ctx) return 0;
if (!safe_read<uint32_t>(ctx + 0x14, &idx) || !idx || idx >= 0x10000)
return 0;
if (!safe_read_ptr(om + 4, &arr) || !arr) return 0;
if (!safe_read_ptr(om + 8, &arr_end)) return 0;
if (idx >= (uint32_t)((arr_end - arr) >> 2)) return 0;
if (!safe_read_ptr(arr + (uintptr_t)idx * 4, &hero) || !hero) return 0;
uint16_t sector = 0; uint32_t hx = 0, hy = 0; uint8_t level = 0;
if (!safe_read<uint16_t>(hero + 0x18, §or)) return 0;
if (!safe_read<uint32_t>(hero + 0x1C, &hx)) return 0;
if (!safe_read<uint32_t>(hero + 0x20, &hy)) return 0;
safe_read<uint8_t>(hero + 0x24, &level);
fn_pos_build build = (fn_pos_build)(reb + 0x006224b0);
fn_obj_create create = (fn_obj_create)(reb + 0x005fba40);
int handle = 0;
__try {
uint8_t pos[16] = { 0 };
// verbatim hero pos bytes — whatever encoding the engine uses
build(pos, sector, hx, hy, level);
handle = create((void*)om, (uint32_t)type, pos, 0, 1, 0);
} __except (EXCEPTION_EXECUTE_HANDLER) {
return handle;
}
return handle;
}
// Spawn `type` at KompassPos (kx,ky) by borrowing the HERO's sector +
// level (valid for anything in/near the hero's 64-tile cell — the
// storyline use case) and converting the target tile to hero-world units
// the same encoding spawn_npc_here proved works: world = (k+0.5)*53.6656.
// Sidesteps the failing FUN_00635c40 resolve. Returns handle or 0.
int spawn_npc_at(int type, int32_t kx, int32_t ky) {
HMODULE exe = g_attach.exe_module;
if (!exe) return 0;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t om = 0, ctx = 0, arr = 0, arr_end = 0, hero = 0;
uint32_t idx = 0;
if (!safe_read_ptr(reb + 0x00AD5C40, &om) || !om) return 0;
if (!safe_read_ptr(reb + 0x0182EBE8, &ctx) || !ctx) return 0;
if (!safe_read<uint32_t>(ctx + 0x14, &idx) || !idx || idx >= 0x10000)
return 0;
if (!safe_read_ptr(om + 4, &arr) || !arr) return 0;
if (!safe_read_ptr(om + 8, &arr_end)) return 0;
if (idx >= (uint32_t)((arr_end - arr) >> 2)) return 0;
if (!safe_read_ptr(arr + (uintptr_t)idx * 4, &hero) || !hero) return 0;
uint16_t sector = 0; uint8_t level = 0;
if (!safe_read<uint16_t>(hero + 0x18, §or)) return 0;
safe_read<uint8_t>(hero + 0x24, &level);
const double B = 53.66563034057617, A = 0.5;
uint32_t wx = (uint32_t)(int32_t)(((double)kx + A) * B);
uint32_t wy = (uint32_t)(int32_t)(((double)ky + A) * B);
fn_pos_build build = (fn_pos_build)(reb + 0x006224b0);
fn_obj_create create = (fn_obj_create)(reb + 0x005fba40);
int handle = 0;
__try {
uint8_t pos[16] = { 0 };
build(pos, sector, wx, wy, level);
handle = create((void*)om, (uint32_t)type, pos, 0, 1, 0);
} __except (EXCEPTION_EXECUTE_HANDLER) {
return handle;
}
return handle;
}
int spawn_npc(int type, int32_t kx, int32_t ky) {
HMODULE exe = g_attach.exe_module;
if (!exe) return 0;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t om = 0;
if (!safe_read_ptr(reb + 0x00AD5C40, &om) || !om) return 0;
fn_pos_build build = (fn_pos_build)(reb + 0x006224b0);
fn_obj_create create = (fn_obj_create)(reb + 0x005fba40);
fn_sector_resolve resolve = (fn_sector_resolve)(reb + 0x00635c40);
// cWorld / sector-map singleton (runtime_spawn.md): *(0x00AD3560),
// null-fallback to *(*(0x00AD5C40)) — both proven the same object.
uintptr_t world = 0;
safe_read_ptr(reb + 0x00AD3560, &world);
if (!world) safe_read_ptr(om, &world); // (*cObjectManager)[0]
if (!world) return 0;
// Recipe (mirrors FUN_004a2b40): build pos with INTEGER tile X/Y,
// resolve the sector via cWorld, then create. resolve()==0 ⇒ the
// tile is off-map / unloaded → abort (don't park a ghost).
int handle = 0;
__try {
uint8_t pos[16] = { 0 }; // {u16 sector@0, i32 X@4, i32 Y@8, u8 lvl@12}
build(pos, 0, (uint32_t)kx, (uint32_t)ky, 0);
char ok = resolve((void*)world, pos);
if (!ok) return 0; // off-map: no spawn
handle = create((void*)om, (uint32_t)type, pos, 0, 1, 0);
} __except (EXCEPTION_EXECUTE_HANDLER) {
return handle;
}
return handle;
}
// Resolve a creature handle (as returned by spawn_npc / engine) to its
// cCreature*. Chain: om = *(0x00AD5C40); arr = *(om+4); creature =
// *(arr + handle*4). Same array world_pos walks. 0 if out of range.
uintptr_t npc_creature(int handle) {
HMODULE exe = g_attach.exe_module;
if (!exe || handle <= 0) return 0;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t om = 0, arr = 0, arr_end = 0, c = 0;
if (!safe_read_ptr(reb + 0x00AD5C40, &om) || !om) return 0;
if (!safe_read_ptr(om + 4, &arr) || !arr) return 0;
if (!safe_read_ptr(om + 8, &arr_end)) return 0;
if ((uint32_t)handle >= (uint32_t)((arr_end - arr) >> 2)) return 0;
if (!safe_read_ptr(arr + (uintptr_t)handle * 4, &c) || !c) return 0;
return c;
}
// Read type (+0x10), KompassPos (from +0x1C/+0x20 world), faction word
// (+0x1F4). Any out-pointer may be null. false if handle unresolvable.
bool npc_info(int handle, int* type, int32_t* kx, int32_t* ky,
uint32_t* faction) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
const double B = 53.66563034057617, A = 0.5;
bool ok = true;
if (type) {
uint32_t t = 0;
ok &= safe_read<uint32_t>(c + 0x10, &t);
*type = (int)(t & 0xFFFF);
}
if (kx || ky) {
int32_t hwx = 0, hwy = 0;
ok &= safe_read<int32_t>(c + 0x1C, &hwx);
ok &= safe_read<int32_t>(c + 0x20, &hwy);
if (kx) *kx = (int32_t)((double)hwx / B - A + 0.5);
if (ky) *ky = (int32_t)((double)hwy / B - A + 0.5);
}
if (faction) ok &= safe_read<uint32_t>(c + 0x1F4, faction);
return ok;
}
bool npc_set_faction(int handle, uint32_t faction) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
return safe_write<uint32_t>(c + 0x1F4, faction);
}
// Activate the creature's AI (the engine path the CreateNPC 0x12 "awake"
// opcode triggers). Without this a runtime-created creature just stands
// there even when hit. cCreature_WakeUp_0059f580 __thiscall(ECX=creature).
typedef void (__thiscall* fn_wake)(void* self);
bool npc_wake(int handle) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
__try { ((fn_wake)(reb + 0x0059F580))((void*)c); }
__except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return true;
}
// Invulnerable / essential flag — the CreateNPC 0x01-payload opcode 0xa1
// path: cCreature `[2].spare |= 0x200000` (byte off 2*8+4 = 0x14).
// `on=false` clears it. Read-modify-write.
bool npc_set_invulnerable(int handle, bool on) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
uint32_t v = 0;
if (!safe_read<uint32_t>(c + 0x14, &v)) return false;
v = on ? (v | 0x200000u) : (v & ~0x200000u);
return safe_write<uint32_t>(c + 0x14, v);
}
// STATIONARY flag — CreateNPC op 0x6b path: byte at cCreature+0x2B7
// (Ghidra [0x56].spare+3) bit 0x08. Set => the creature holds its post
// (no patrol/wander); clear => free to roam.
bool npc_set_stationary(int handle, bool on) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
uint8_t b = 0;
if (!safe_read<uint8_t>(c + 0x2B7, &b)) return false;
b = on ? (uint8_t)(b | 0x08) : (uint8_t)(b & ~0x08);
return safe_write<uint8_t>(c + 0x2B7, b);
}
// Stance / behavior. FUN_0052e420 __thiscall(ECX=creature, int mode, u32):
// mode 0 -> creature+0x1F0 = class-default stance (FUN_0043adc0(type)):
// a Skeleton then behaves like a real vanilla skeleton
// (hostile), townsfolk neutral, etc. THE missing piece — a
// runtime-created creature has +0x1F0 = 0 ⇒ aimless wander.
// mode 1 -> creature+0x1F0 = value (explicit stance override).
typedef void (__thiscall* fn_stance)(void* self, int mode, uint32_t val);
bool npc_set_stance(int handle, int mode, uint32_t value) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
__try { ((fn_stance)(reb + 0x0052E420))((void*)c, mode, value); }
__except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return true;
}
// ── Companions + dynamic NPC behavior ─────────────────────────────────
// RE: combat_init.md "## Companions + dynamic NPC behavior". All HIGH-
// confidence (raw-disasm field semantics + already-proven engine ABIs
// FUN_0052e420 / FUN_0059f580 / cObjectManager destroy). Hero player
// slot = *(ctx+0x14), ctx=*(0x0182EBE8) (same chain as world_pos).
static uint32_t hero_slot(uintptr_t reb) {
uintptr_t ctx = 0; uint32_t idx = 0;
if (!safe_read_ptr(reb + 0x0182EBE8, &ctx) || !ctx) return 0;
if (!safe_read<uint32_t>(ctx + 0x14, &idx)) return 0;
if (idx == 0 || idx > 0x10) return 0; // valid player slot 1..16
return idx;
}
// Hero cCreature* (ctx+0x14 player slot -> object-manager array). 0 if the
// chain is not resolvable (menu / loading).
static uintptr_t hero_creature_ptr(uintptr_t reb) {
uintptr_t om = 0, ctx = 0, arr = 0, arr_end = 0, hero = 0; uint32_t idx = 0;
if (!safe_read_ptr(reb + 0x00AD5C40, &om) || !om) return 0;
if (!safe_read_ptr(reb + 0x0182EBE8, &ctx) || !ctx) return 0;
if (!safe_read<uint32_t>(ctx + 0x14, &idx) || !idx || idx > 0x10) return 0;
if (!safe_read_ptr(om + 4, &arr) || !arr) return 0;
if (!safe_read_ptr(om + 8, &arr_end)) return 0;
if (idx >= (uint32_t)((arr_end - arr) >> 2)) return 0;
if (!safe_read_ptr(arr + (uintptr_t)idx * 4, &hero) || !hero) return 0;
return hero;
}
// COMPANION — the engine's own script-'follow' path (LIVE-CONFIRMED
// 2026-09-10, npc_ai_flags.md "Companion mechanisms"): deliver command 0x10B
// {a=4, b=hero slot} to the creature's vfn[6] (cCreature receive,
// FUN_0052e590). The handler sets +0x251=owner, +0x1F4|=4 (follow/pet AI
// mode: leash, catch-up, fights for the owner, treated as the hero for
// friend/foe), calls WakeUp and registers the creature into the hero's party
// vector hero+0x39c via FUN_0054b200 — which is what the companion panel
// draws. The old hand-rolled recipe did everything except that registration,
// so companions followed and fought but never got a portrait. Level is kept
// (unlike the 'hireling' path FUN_0054cf70, which re-levels to the hero).
typedef void* (__thiscall* fn_recv_cmd)(void* self, void* cmd);
bool npc_make_companion(int handle, bool combat) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uint32_t hs = hero_slot(reb);
if (!hs) { sdk_log("[companion] h=%d no hero slot", handle);
return false; }
uintptr_t vt = 0, fn = 0;
if (!safe_read_ptr(c, &vt) || !vt || !safe_read_ptr(vt + 0x18, &fn) || !fn) {
sdk_log("[companion] h=%d vtable unresolved", handle);
return false;
}
uint32_t cmd[0x11] = { 0 }; // 0x44-byte command object
cmd[0] = (uint32_t)(reb + 0x0089095C); // PTR_FUN_0089095c vtable
cmd[1] = 0x10B; // 'follow'
cmd[5] = 4; // sub-op (handler requires 4)
cmd[6] = hs; // owner = hero player slot
uint32_t f = 0;
__try {
uint8_t b = 0; // un-stationary
if (safe_read<uint8_t>(c + 0x2B7, &b))
safe_write<uint8_t>(c + 0x2B7, (uint8_t)(b & ~0x08));
if (safe_read<uint32_t>(c + 0x1F4, &f) && (f & 0x40000u))
safe_write<uint32_t>(c + 0x1F4, f & ~0x40000u); // never the inverted-friend mode
((fn_recv_cmd)fn)((void*)c, (void*)cmd); // engine: owner, |4, WakeUp, party
((fn_stance)(reb + 0x0052E420))((void*)c, 1, 7); // +0x1F0=7 ally
if (combat) {
// COMBAT companion = the hireling AI mode (0x100) instead of the
// follow/pet mode (4). The target picker FUN_00542b20 only lets a
// party member JOIN THE HERO'S FIGHT when its +0x1F4 has 0x100
// (or 0x10000) — mode-4 followers only defend themselves. Same
// registration/owner as above, but WITHOUT FUN_0054cf70's level
// re-sync (a level-18 guard stays level 18).
if (safe_read<uint32_t>(c + 0x1F4, &f))
safe_write<uint32_t>(c + 0x1F4, (f & ~0x4u) | 0x100u);
}
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
sdk_log("[companion] h=%d -> hero slot %u via cmd 0x10B (%s, party panel)",
handle, hs, combat ? "hireling AI 0x100" : "follow AI 4");
return true;
}
// DISMISS (exact inverse): native party removal FUN_00551300(ECX=hero, handle)
// drops the hero+0x39c entry (panel portrait) and plays the 'left' sound;
// then the engine's own field resets (+0x251=-1, follow/hireling mode bits
// cleared) and an independent neutral matrix class.
typedef int (__thiscall* fn_party_remove)(void* hero, int handle);
bool npc_dismiss(int handle) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t hero = hero_creature_ptr(reb);
uint32_t f = 0;
__try {
if (hero) ((fn_party_remove)(reb + 0x00551300))((void*)hero, handle);
if (safe_read<uint32_t>(c + 0x1F4, &f))
safe_write<uint32_t>(c + 0x1F4, f & ~0x104u); // 4 = follow, 0x100 = hireling
safe_write<uint32_t>(c + 0x251, 0xFFFFFFFFu);
((fn_stance)(reb + 0x0052E420))((void*)c, 1, 3); // neutral
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
sdk_log("[companion] h=%d dismissed (party entry removed, hero=%s)", handle,
hero ? "ok" : "unresolved");
return true;
}
// COMPANION ROSTER PANEL (Q2, quest_lifecycle.md). The on-screen
// escort/companion list is driven by the cQuestMgr quest-NPC array at
// cQuestMgr+0x31c(begin)/+0x320(end), stride 0x34; each live entry
// (+0x14!=0) holds a std::vector of 0x2c-byte member sub-records at
// +0x1c(begin)/+0x20(end)/+0x24(cap). A creature joins via
// FUN_00450C50(__thiscall ECX=cCreature, i16 idx; ret 8 — sole
// persistent store *(i16)(c+0x94)=idx, rest is SP-skipped net) plus
// +0x200|=0x200, +0x96=quest_id, and one 0x2c sub-record
// {[0]=*(c+0xc) handle, [1]=0x100, [2](i16)=idx} pushed into the
// entry's vector. CRASH-SAFE CHOICE: push IN-PLACE only when the
// vector has spare capacity (end!=cap); if full we DON'T call the
// engine grow FUN_004B82E0 (its ABI is not pinned — guessing it = the
// realloc crashes we will not repeat) — we no-op (companions still
// follow/fight via the +0x1F4/+0x251 path; only the panel face is
// skipped). Slot = first live entry (+0x14!=0); scanned, not guessed.
typedef void (__thiscall* fn_roster_join)(void* c, int idx); // 0x450C50
// Read-only diagnostic: dump the quest-NPC roster array (qm+0x31c, stride 0x34)
// so we can see whether ANY live entry exists and capture a vanilla entry's
// 0x34-byte layout (needed to natively create our own entry). Pure reads.
void roster_dump(const char* tag) {
HMODULE exe = g_attach.exe_module; if (!exe) return;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t qm = engine::singletons::qm(reb);
uintptr_t b = 0, e = 0, cap = 0;
bool okb = safe_read_ptr(qm + 0x31c, &b);
bool oke = safe_read_ptr(qm + 0x320, &e);
safe_read_ptr(qm + 0x324, &cap);
int cnt = (okb && oke && b && e >= b) ? (int)((e - b) / 0x34) : -1;
sdk_log("[roster-dump:%s] qm=%p +31c begin=%08X end=%08X cap=%08X count=%d",
tag, (void*)qm, (unsigned)b, (unsigned)e, (unsigned)cap, cnt);
if (cnt <= 0 || cnt > 4096) return;
__try {
for (int i = 0; i < cnt && i < 16; i++) {
uintptr_t en = b + (uintptr_t)i * 0x34;
uint32_t w[13];
for (int k = 0; k < 13; k++) safe_read<uint32_t>(en + k * 4, &w[k]);
uintptr_t vb = w[7], ve = w[8], vc = w[9]; // +0x1c/+0x20/+0x24
int members = (vb && ve >= vb) ? (int)((ve - vb) / 0x2c) : -1;
sdk_log("[roster-dump:%s] #%d @%08X live(+14)=%08X | +00=%08X +04=%08X "
"+08=%08X +10=%08X | vec b=%08X e=%08X c=%08X members=%d",
tag, i, (unsigned)en, w[5], w[0], w[1], w[2], w[4],
(unsigned)vb, (unsigned)ve, (unsigned)vc, members);
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
sdk_log("[roster-dump:%s] faulted", tag);
}
}
bool npc_roster_add(int handle, int quest_id) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t qm = engine::singletons::qm(reb);
roster_dump("add"); // capture array state every add
uintptr_t b = 0, e = 0;
if (!safe_read_ptr(qm + 0x31c, &b) || !b) {
sdk_log("[roster] h=%d ABORT: qm+0x31c array is NULL/empty (no quest-NPC "
"slot exists — needs native entry creation)", handle);
return false;
}
if (!safe_read_ptr(qm + 0x320, &e) || e < b) return false;
int cnt = (int)((e - b) / 0x34);
if (cnt <= 0 || cnt > 4096) {
sdk_log("[roster] h=%d ABORT: array count=%d (empty)", handle, cnt);
return false;
}
bool done = false;
__try {
for (int i = 0; i < cnt; i++) {
uintptr_t entry = b + (uintptr_t)i * 0x34;
uint32_t live = 0;
if (!safe_read<uint32_t>(entry + 0x14, &live) || live == 0)
continue; // need a live slot
uintptr_t vb = 0, ve = 0, vc = 0;
if (!safe_read_ptr(entry + 0x1c, &vb) ||
!safe_read_ptr(entry + 0x20, &ve) ||
!safe_read_ptr(entry + 0x24, &vc)) continue;
if (ve == vc || ve < vb) continue; // no spare cap -> skip
// identity stamps (HIGH: capstone-exact single stores)
((fn_roster_join)(reb + 0x00450C50))((void*)c, i); // +0x94=i
safe_write<int16_t>(c + 0x96, (int16_t)quest_id);
uint32_t v200 = 0;
if (safe_read<uint32_t>(c + 0x200, &v200))
safe_write<uint32_t>(c + 0x200, v200 | 0x200u);
// push the 0x2c sub-record IN PLACE (spare cap confirmed).
uint32_t hc = 0; safe_read<uint32_t>(c + 0x0c, &hc);
for (int k = 0; k < 0x2c; k += 4)
safe_write<uint32_t>(ve + k, 0);
safe_write<uint32_t>(ve + 0x00, hc); // member handle
safe_write<uint32_t>(ve + 0x04, 0x100);
safe_write<int16_t> (ve + 0x08, (int16_t)i);
safe_write<uint32_t>(entry + 0x20,
(uint32_t)(ve + 0x2c)); // end+=0x2c
sdk_log("[roster] h=%d -> array slot %d (q=%d) panel-add",
handle, i, quest_id);
done = true;
break;
}
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
if (!done) sdk_log("[roster] h=%d no usable slot (panel skipped; "
"follow/fight still active)", handle);
return done;
}
// REMOVE: inverse — clear +0x200&0x200, +0x94=-1, and compact the
// member's 0x2c sub-record out of its array-entry vector (copy the
// last record over it, end-=0x2c). All in-place, SEH-guarded.
bool npc_roster_remove(int handle) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t qm = engine::singletons::qm(reb);
int16_t idx = -1;
__try {
safe_read<int16_t>(c + 0x94, &idx);
uint32_t v200 = 0;
if (safe_read<uint32_t>(c + 0x200, &v200))
safe_write<uint32_t>(c + 0x200, v200 & ~0x200u);
uint32_t hc = 0; safe_read<uint32_t>(c + 0x0c, &hc);
uintptr_t b = 0;
if (idx >= 0 && safe_read_ptr(qm + 0x31c, &b) && b) {
uintptr_t entry = b + (uintptr_t)idx * 0x34;
uintptr_t vb = 0, ve = 0;
if (safe_read_ptr(entry + 0x1c, &vb) &&
safe_read_ptr(entry + 0x20, &ve) && ve > vb) {
for (uintptr_t p = vb; p + 0x2c <= ve; p += 0x2c) {
uint32_t rh = 0;
if (safe_read<uint32_t>(p, &rh) && rh == hc) {
uintptr_t last = ve - 0x2c;
if (p != last)
for (int k = 0; k < 0x2c; k += 4) {
uint32_t w = 0;
safe_read<uint32_t>(last + k, &w);
safe_write<uint32_t>(p + k, w);
}
safe_write<uint32_t>(entry + 0x20,
(uint32_t)last); // end-=0x2c
break;
}
}
}
}
safe_write<int16_t>(c + 0x94, (int16_t)-1);
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
sdk_log("[roster] h=%d removed from panel", handle);
return true;
}
// DESPAWN (C-2): clean engine removal. DelNPC 0x37 handler FUN_00497f80
// calls cObjectManager destroy leaf @0x005FBDB0 __cdecl(handle,1,0,1).
typedef void (__cdecl* fn_destroy)(int handle, int, int, int);
bool npc_despawn(int handle) {
uintptr_t c = npc_creature(handle);
if (!c) return false; // already gone
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
__try { ((fn_destroy)(reb + 0x005FBDB0))(handle, 1, 0, 1); }
__except (EXCEPTION_EXECUTE_HANDLER) { return false; }
sdk_log("[npc] h=%d despawned", handle);
return true;
}
// SET DISPOSITION (C-1): +0x1F0 matrix class via FUN_0052e420(1,val),
// then WakeUp re-aggro so it takes effect mid-game. Caller passes the
// matrix class value (hostile-to-hero=2, ally=7, neutral/immune=13).
bool npc_set_disposition(int handle, uint32_t matrix_class) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
__try {
((fn_stance)(reb + 0x0052E420))((void*)c, 1, matrix_class);
((fn_wake)(reb + 0x0059F580))((void*)c); // re-aggro
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
sdk_log("[npc] h=%d disposition -> matrix class %u", handle,
matrix_class);
return true;
}
// Set level/rank. CORRECTED (combat_init.md): FUN_0044ddc0 is the
// facing/orientation setter (cos/sin → +0x70/74/78), NOT level — that was
// an npc_model.md erratum and is why "level had no effect". Level/rank is
// the plain u8 at cCreature+0x24. HP auto-scales from it once the creature
// is an active combatant (see npc_make_combatant).
bool npc_set_level(int handle, int level) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
if (level < 1) level = 1; if (level > 255) level = 255;
return safe_write<uint8_t>(c + 0x24, (uint8_t)level);
}
// Turn a bare cObjectManager::create'd creature into a REAL combatant by
// replaying CreateNPC's post-create block (combat_init.md §2), which the
// bare spawn path skips — this is why our NPCs were 1-hit and passive:
// B level *(u8*)(c+0x24) = level (HP scales from this)
// C AI class FUN_0052e420(ECX=c, 1, ai_class) → c+0x1F0=ai_class
// D faction commit *(u32*)(c+0x1F4) = 1 (bit0 awake; NO 0x40000
// 'peaceful' bit → stays proactive)
// E WakeUp FUN_0059f580(ECX=c) (needs +0x200&0x40000==0,+0xfc==0)
// F arm AI ctrl *(u32*)(c+0x200) = 0x40200000
// + clear STATIONARY (+0x2B7 bit8) so it can move to engage
// ai_class (hostility-matrix class @0x00890A30, [A*16+B]; CORRECTED
// polarity: matrix byte 0x00 = A ATTACKS B, 0x01 = friendly/ignore —
// the prior note had it inverted, which is why class 2 wrongly attacked
// the hero):
// 3 (or 7) = ALLY DEFENDER — attacks monsters, NEVER the hero/allies
// (hero is class 1; M[3][1]=friendly). This is the correct value
// and matches CreateNPC's no-side default (00482510:1114).
// 2 = a MONSTER class (hostile to the class-1 hero) — do NOT use for allies.
// 1 = hero/player cluster; 13 = immune non-combatant (town/quest).
typedef void (__thiscall* fn_stance2)(void* self, int mode, uint32_t val);
typedef void (__thiscall* fn_wake2)(void* self);
bool npc_make_combatant(int handle, int level, int ai_class) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
if (level < 1) level = 1; if (level > 255) level = 255;
// CORRECTED from a live diff vs a real vanilla Valorian Soldier
// (type 257, class 7): the combat_init.md reconstruction had two
// RE errors that caused the Seraphim-BFG combat-art + the red glow:
// - +0x1F4 must be 0x00400000 (the active-soldier faction value),
// NOT 0x1. The wrong value mis-drove faction/attack selection.
// - +0x200 must stay 0x00000000. We were forcing the magic
// 0x40200000 ("AI arm") — that is the extra bit that hung the
// Seraphim energy combat-art + glow on the creature. Vanilla
// soldiers have +0x200 == 0 in steady state; WakeUp alone
// activates the AI (it sets +0xfe / copies +0x204->+0x208).
__try {
*(uint8_t*)(c + 0x24) = (uint8_t)level; // B level
((fn_stance2)(reb + 0x0052E420))((void*)c, 1,
(uint32_t)ai_class); // C +0x1F0
*(uint32_t*)(c + 0x1F4) = 0x00400000; // D faction (vanilla value)
// WakeUp precondition: +0x200 bit 0x40000 clear (vanilla +0x200=0)
uint32_t f200 = 0;
if (safe_read<uint32_t>(c + 0x200, &f200) && (f200 & 0x40000u))
safe_write<uint32_t>(c + 0x200, f200 & ~0x40000u);
((fn_wake2)(reb + 0x0059F580))((void*)c); // E WakeUp
// F: do NOT write +0x200 (leave engine/vanilla 0 — the RE error).
uint8_t b = 0; // un-stationary
if (safe_read<uint8_t>(c + 0x2B7, &b))
safe_write<uint8_t>(c + 0x2B7, (uint8_t)(b & ~0x08));
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return true;
}
// ===========================================================================
// Storyline runtime layer (RE: .claude/knowledge/re/quest_storyline.md, items.md,
// triggers_dialog_move.md). All additive, __try-guarded, non-destructive.
// cQuestMgr singleton = 0x00AACF80 (ECX in every quest/dialog handler).
// ===========================================================================
// --- B) custom NPC display name -------------------------------------------
// The name a quest/dialog NPC shows is NOT on cCreature; it lives, keyed by
// the creature handle at entry+0, in two cQuestMgr vectors:
// NameArrA : begin qm+0x358 end qm+0x35c stride 0x44 name @ entry+0x04
// DlgNPC : begin qm+0x755c end qm+0x7560 stride 0x50 name @ entry+0x04
// We overwrite the in-place fixed buffer of whichever entry already keys
// this handle (engine-faithful: it finds entries the same way, entry[0]==h).
// NOTE: a *purely* runtime-spawned creature (cObjectManager::create, not the
// FunkCode CreateNPC handler) usually has NO such entry yet, so this is a
// no-op for it until a DlgNPC entry exists (open item — see the report).
// Returns true only if an entry was found and rewritten.
static bool qm_name_write(uintptr_t begin_ptr, uintptr_t end_ptr,
unsigned stride, int handle, const char* name) {
uintptr_t b = 0, e = 0;
if (!safe_read_ptr(begin_ptr, &b) || !b) return false;
if (!safe_read_ptr(end_ptr, &e) || e < b) return false;
for (uintptr_t p = b; p + stride <= e; p += stride) {
uint32_t key = 0;
if (!safe_read<uint32_t>(p, &key)) break;
if (key == (uint32_t)handle) {
char* dst = (char*)(p + 4);
unsigned cap = stride - 4; // bytes available for the name
if (IsBadWritePtr(dst, cap)) return false;
unsigned i = 0;
for (; name[i] && i < cap - 1; ++i) dst[i] = name[i];
dst[i] = 0;
return true;
}
}
return false;
}
bool set_npc_name(int handle, const char* name) {
HMODULE exe = g_attach.exe_module;
if (!exe || !name) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t qm = engine::singletons::qm(reb);
bool any = false;
__try {
// DlgNPC (the dialog/QUESTNPC/tooltip array — stride 0x50)
any |= qm_name_write(qm + 0x755c, qm + 0x7560, 0x50, handle, name);
// NameArrA (CreateNPC name buffer — stride 0x44)
any |= qm_name_write(qm + 0x358, qm + 0x35c, 0x44, handle, name);
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return any;
}
// --- C) overhead quest-giver marker ---------------------------------------
// CORRECTED (2026-05-16, see quest_storyline.md "Red-FX side-effect"):
// * The earlier recipe was WRONG. cCreature+0x200 bits 0x1000/0x2000/
// 0x4000/0x4000000 are NPC CLASS-IDENTITY flags (smith / trader /
// combat-arts-MASTER / trader2) — CreateNPC sets +0x200|0x4000 for the
// Master-of-Combat-Arts class. Writing it re-classed the NPC as a
// combo-arts master ⇒ its native combo icon + the trainer's red/orange
// swirl aura. And cCreature+0x14 bit 0x80000 is overloaded: the 3D
// model renderer (FUN_0044b230) turns it into a particle swirl. So
// NEITHER cCreature write is correct — do NOT poke +0x200 or +0x14.
// * Vanilla quest-givers set ONLY the DlgNPC entry sprite field
// (entry+0x48) via SetIcon (FUN_004a1a50): value 0x0b = "has quest /
// talk to me" bubble (NPC_DIALOG_02.TGA), 0x08 = cleared. The renderer
// shows it when the engine has bound the NPC (it sets +0x14 itself).
// * A purely runtime-spawned NPC has NO DlgNPC entry (only NameArrA) —
// same open item as set_npc_name. So this is the engine-faithful,
// side-effect-free write IF an entry exists, else an honest no-op
// (returns false). It will NEVER corrupt the NPC again.
// DlgNPC: begin *(qm+0x755c) end *(qm+0x7560) stride 0x50, key@+0=handle,
// sprite@+0x48. qm (cQuestMgr) = 0x00AACF80.
// "?!" combo quest marker. RE re-evaluated 2026-05-16
// (quest_storyline.md "Yellow \"?!\" marker — re-evaluated"): the engine
// has exactly ONE exclam+question glyph = selector case 0x22 ->
// npc_dialog_combo.tga (no yellow variant exists; color is baked in the
// TGA; vanilla secondary quests actually reuse "!" 0x0b). The marker
// glyph selector FUN_00499e90 priority: cCreature+0x200 bit 0x4000 ->
// case 4 -> glyph 0x22, and that case WINS over the DlgNPC entry+0x48
// path (which has an unresolved objIdx-range MED). The old "+0x200 =>
// red aura" ban was the DISPROVEN glow theory; the real glow is the
// invuln ward FX (+0x14&0x200000), unrelated. +0x200&0x4000's ONLY
// readers are this selector and the minimap quest-dot — no FX/combat/
// behaviour reader. So for a (stationary, immortal) quest NPC this is
// fully side-effect-free. Recipe: set cCreature+0x200|=0x4000
// (deterministic "?!", no BP) AND DlgNPC entry+0x48=0x22 (engine-
// faithful, mirrors vanilla FUN_004a1a50); ensure the +0x14&0x80000
// draw gate (bind already sets it). on=false reverts both.
static uintptr_t dlg_entry_by_objidx(uintptr_t reb, int handle,
int* out_idx, int* out_cnt);
bool npc_quest_icon(int handle, bool on) {
HMODULE exe = g_attach.exe_module;
if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t qm = engine::singletons::qm(reb);
// Overhead-icon atlas DEFINITIVE (quest_storyline.md, textures
// extracted from pak/texture.pak — ground truth): the genuine "?!"
// is NPC_DIALOG_01.TGA, selected by DlgNPC entry+0x48 = 0x0A (vanilla
// uses 0x0a 359x for "?!"). 0x0B = "!" (NPC_DIALOG_02), 0x08 = off.
// NEVER write cCreature+0x200 — those are CLASS icons (0x4000 = the
// yellow combat-arts-MASTER figure, the earlier wrong result).
bool any = false;
uintptr_t c = npc_creature(handle);
if (c) { // ensure the draw gate only
__try {
uint32_t v14 = 0;
if (on && safe_read<uint32_t>(c + 0x14, &v14))
safe_write<uint32_t>(c + 0x14, v14 | 0x80000u);
} __except (EXCEPTION_EXECUTE_HANDLER) {}
}
// Write entry+0x48 at the entry the SELECTOR reads = by-objIdx
// (cCreature+0x245), NOT by-handle scan — those can differ, which is
// why the marker silently vanished (engine read a different entry).
(void)qm;
uintptr_t oe = dlg_entry_by_objidx(reb, handle, nullptr, nullptr);
if (oe) {
__try {
if (safe_write<uint32_t>(oe + 0x48, on ? 0x0Au : 0x08u))
any = true; // 0x0A = "?!"
} __except (EXCEPTION_EXECUTE_HANDLER) {}
}
return any;
}
// --- spawn a world ITEM ----------------------------------------------------
// items.md big result: items and creatures share ONE create path and ONE
// type-id space. Spawning a pickup-able ground item is EXACTLY the proven
// creature recipe with an item type id — so this just forwards to the
// hero-sector spawn that already works. `type` = an item type id.
int spawn_item(int type, int32_t kx, int32_t ky) {
return spawn_npc_at(type, kx, ky);
}
// --- teleport ANY npc (engine path, non-destructive) ----------------------
// FUN_0054d9d0 is __thiscall(ECX=creature, x, y, level, flag); set_world_pos
// already proves it for the hero in KompassPos units. The Teleport(0x2e)
// FunkCode handler uses this same function on the target creature, so the
// only change is ECX = the NPC's cCreature*. Returns false if the engine
// rejected the placement (returns 0 — non-destructive, retry next tick).
bool npc_teleport(int handle, int32_t kx, int32_t ky) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
int rv = 0;
__try {
rv = ((fn_engine_tp)(reb + 0x0054D9D0))((void*)c,(int)kx,(int)ky,0,1);
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return rv != 0;
}
// --- equip an item into an NPC slot (EXPERIMENTAL) ------------------------
// Manual two-step from items.md §3/§6: create the item object position-less
// via cObjectManager::create_005fb530(ECX=om, type,0,1,0) then
// cCreature::equipment_equip_00555e00(ECX=creature, slot, itemRef, sendNet).
// Slot map cross-confirmed by the hero equipment table in read():
// 0=helmet .. 0xC=weapon_l 0xD=weapon_r .. 0x12=mount (cCreature+0x1A4+slot*4)
// `item_type` = item type id (same id space as creatures). Guarded; if the
// create_005fb530 arity guess is wrong the __try contains it (returns false).
// Confidence MED — do not call on the live campaign until BP-confirmed.
typedef int (__thiscall* fn_obj_create_npos)(void* self, uint32_t type,
uint32_t a3, char a4,
uint32_t a5);
typedef int (__thiscall* fn_equip)(void* self, uint32_t slot, int itemRef,
char sendNet);
bool npc_equip(int handle, int item_type, int slot) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
HMODULE exe = g_attach.exe_module; if (!exe) return false;
uintptr_t reb = reinterpret_cast<uintptr_t>(exe) - 0x00400000;
uintptr_t om = 0;
if (!safe_read_ptr(reb + 0x00AD5C40, &om) || !om) return false;
__try {
fn_obj_create_npos mk = (fn_obj_create_npos)(reb + 0x005FB530);
fn_equip eq = (fn_equip)(reb + 0x00555E00);
int itemRef = mk((void*)om, (uint32_t)item_type, 0, 1, 0);
if (!itemRef) return false;
eq((void*)c, (uint32_t)slot, itemRef, 1);
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return true;
}
// NOTE: the diagnostic dumps dump_vanilla_of / hero_weapon_dump /
// scan_creatures / npc_field_dump moved to engine/debug.cpp (refactor A4).
// --- set creature HP -----------------------------------------------------
// cCreature+0x4d8 = current HP (PROVEN: zeroing it made the captain a
// "dead but still talks" corpse). Runtime-spawned creatures get weak
// default stats (the FunkCode CreateNPC path does Balance.bin stat init we
// skip), so soldiers die in one hit. Bump current HP; we also probe a few
// neighbour dwords for the max-HP field and raise it too so the engine
// doesn't instantly clamp/regen back down. Returns false if unresolved.
bool npc_set_hp(int handle, int hp) {
uintptr_t c = npc_creature(handle);
if (!c) return false;
bool ok = false;
__try {
// combat_init.md: +0x4d4 = MAX HP (the clamp source) — writing
// only +0x4d8 (current) gets clamped back down next tick (the
// "dies in one hit"). Write the whole vitality pair.
ok = safe_write<uint32_t>(c + 0x4d4, (uint32_t)hp); // max
ok &= safe_write<uint32_t>(c + 0x4d8, (uint32_t)hp); // current
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return ok;
}
// --- spawn via the engine's OWN CreateNPC handler (the right way) --------
// Strategic pivot (npc_templates.md): instead of reconstructing combat
// init by poking cCreature bits (endless edge bugs: BFG/glow/1HP/no-
// retaliate), synthesize a dev-authored CreateNPC TLV record from
// npc_templates.lua and let the ENGINE's own FUN_00482510 spawn+init it
// — type-correct HP/AI/faction/combat-arts, exactly like a hand-placed
// dev NPC. FUN_00482510 = __thiscall, ECX = the cQuestMgr/interpreter
// context (exe_base+0x00AACF80, same ctx dlgnpc_bind already drives),
// stack arg = on-disk TLV record buffer (cursor self-inits to 4).
// Header must be `01 00 <size&0xff>` (size guard reads u16@+2 native-LE;
// records <256 B). `payload` = npc_templates M.build() bytes
// (flags 0x00 + opcode stream + END). want_type = the template's
// creature type id, used to disambiguate the new handle. Returns the new
// creature handle (best-effort) or 0.
// ── Runtime dialog arming (R-B) ───────────────────────────────────────
// Replay a Dialog/Button/Sound TLV record through the engine's OWN
// record dispatcher FUN_00475680, exactly like vanilla/save/MP. ABI is
// disasm-pinned (dialog_runtime.md "Dialog-arming call ABI — pinned"):
// __thiscall, ECX=qm=exe+0x00AACF80, SIX stack args, ret 0x18.
// p1=buffer base, p2=&cursor(int; =0 → record at buf+0; engine
// advances *cursor by the BE size), p3=-1, p4=0 (no net),
// p5=0 (no TalkTo pre-bind), p6=0. Modeled on the proven
// createnpc_engine invocation (same __thiscall/ECX=qm/SEH pattern).
// Record framing: tag:u8 | size:u16 BE incl 3-byte header | payload,
// payload[0]=0x00 lead byte, then opcode-1 ASCIIZ fields, 0x00 END.
// Do NOT pre-resolve text via FUN_00672740 (crashes pre-cache); the
// dispatcher's own FUN_00472bc0 parse resolves the global.res key at
// replay time — pass the BARE registered name (no "res:" prefix).
typedef int (__thiscall* fn_funk_dispatch)(
void* qm, const void* buf, int* cursor,
int p3, int p4, char p5, int p6);
static size_t dlg_put_rec(uint8_t* o, uint8_t tag,
const char* s1, const char* s2) {
size_t p = 3; o[p++] = 0x00; // lead flags byte
o[p++] = 0x01; while (s1 && *s1) o[p++] = (uint8_t)*s1++; o[p++] = 0;
if (s2) { o[p++] = 0x01; while (*s2) o[p++] = (uint8_t)*s2++;
o[p++] = 0; }
o[p++] = 0x00; // END
o[0] = tag; o[1] = (uint8_t)(p >> 8); o[2] = (uint8_t)(p & 0xff);
return p; // == total length
}
// Build a tag-0x03 DialogShow record: field-1 = the text key (must carry the
// "res:" prefix so FUN_00472bc0 case 1 takes the global.res resolve branch),
// field-9 = the DlgNPC bind name, trailing 0x39 = button/flag field, then END.
// The walker (FUN_00475680) self-resolves field-1 res:NAME through
// FUN_006726f0->FUN_0080e780->sacred_hash->global.res — so the NPC shows OUR
// baked text with no content-handle hack. See .claude/knowledge/re/
// dialog_impl_plan.md (verified against 1208 live tag-0x03 records).
static size_t dlg_put_dialog_rec(uint8_t* o, const char* res_key,
const char* dlg_name) {
size_t p = 3; o[p++] = 0x00; // lead flags byte
o[p++] = 0x01; while (res_key && *res_key) o[p++] = (uint8_t)*res_key++;
o[p++] = 0; // fid 1: res:<KEY>
if (dlg_name && *dlg_name) {
o[p++] = 0x09; while (*dlg_name) o[p++] = (uint8_t)*dlg_name++;
o[p++] = 0; // fid 9: DlgNPC name
}
// (2026-09-10) the trailing 0x39 that used to be here is NOT a "button
// field": in the DialogShow grammar it is the script keyword `unfollow`
// (FUN_0048bb40 case 0x39 -> consequence 0x400 -> FUN_00461540 sends
// cmd 0x10B owner=0). Vanilla escort blocks use 0x37 (`follow`) on the
// "come with me" lines and 0x39 on the neutral ones. We carry no
// consequence in the armed record; companions are made explicitly.
o[p++] = 0x00; // END
o[0] = 0x03; o[1] = (uint8_t)(p >> 8); o[2] = (uint8_t)(p & 0xff);
return p;
}
// p4 = the speaking NPC's cCreature* (dispatcher does
// ebp=RTDynamicCast<cCreature*>(p4); FUN_0048f9e0 skips ENTIRELY if that
// is 0 — the old p4=0 bug = silent no-op). p5 = TalkTo pre-bind gate
// (1 = run it, needed for an interactive bind). cursor=0 in-memory;
// p3=-1 only matters for tag-6/0x10 (irrelevant to 0x1f/0x68).
static bool funk_replay_one(uintptr_t reb, const uint8_t* rec, size_t n,
void* p4_creature, char p5) {
void* qm = (void*)(engine::singletons::qm(reb));
uint8_t buf[0x208] = {0}; // < 0x800 walker cap
if (n == 0 || n > sizeof(buf)) return false;
for (size_t k = 0; k < n; ++k) buf[k] = rec[k];
int cursor = 0;
__try {
((fn_funk_dispatch)(reb + 0x00475680))(
qm, buf, &cursor, -1, (int)(uintptr_t)p4_creature, p5, 0);
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
return true;
}
// global.res resource-name hash = engine FUN_0080e780. EXACT port of
// custom/lua/lib/text.lua `sacred_hash` (verified vs 823 ids + the
// working captain NAME). The resolvable content handle the engine's
// dialog GUI expects in entry+0x4c is `sacred_hash(name) | 0x80000000`
// (FUN_006726f0: high bit set -> globalres[h & 0x7fffffff]). Computed
// here so we do NOT call the engine resolver FUN_00672740 (it crashes
// uncatchably if hit before its cache exists).
static uint32_t sacred_hash(const char* s) {
const uint32_t MOD = 999999991u, MUL = 113u;
uint32_t h = 0;
for (; s && *s; ++s) {
uint32_t oc = (uint8_t)*s;
if (oc >= 0x61 && oc <= 0x7a) oc -= 0x20; // toupper
uint32_t prod = (uint32_t)(((uint64_t)h * MUL) & 0xFFFFFFFFu);
uint32_t su = (oc + prod) & 0xFFFFFFFFu;
int64_t si = (su >= 0x80000000u)
? (int64_t)su - 0x100000000LL : (int64_t)su;
int64_t r = (si >= 0) ? (si % MOD) : -((-si) % MOD);
if (r < 0) r += 0x100000000LL;
h = (uint32_t)(r & 0xFFFFFFFFu);
}
return h & 0x7FFFFFFFu;
}
// The DlgNPC entry the ENGINE actually reads for markers/dialog is
// indexed by objIdx = *(u8/u32)(cCreature+0x245) (selector FUN_00499e90
// case 6: qm+0x755c + objIdx*0x50), NOT by scanning entry+0==handle.
// Returns that entry ptr (validated < count), 0 if objIdx out of range.
static uintptr_t dlg_entry_by_objidx(uintptr_t reb, int handle,
int* out_idx, int* out_cnt) {
uintptr_t qm = engine::singletons::qm(reb), b = 0, e = 0;
if (out_idx) *out_idx = -1;
if (out_cnt) *out_cnt = -1;
uintptr_t c = npc_creature(handle);
if (!c) return 0;
uint32_t idx = 0;
// +0x245 is a DWORD: FUN_005498F0 stores it with a 32-bit mov and every engine
// access is 32-bit. The old byte read returned idx & 0xFF; dialog_arm fed that
// into FUN_00463240, which re-stamped the creature onto the VANILLA element at
// idx & 0xFF (S0b 2026-09-11, .claude/knowledge/quests/LIVE_S0_RESULTS.md).
if (!safe_read<uint32_t>(c + 0x245, &idx)) return 0;
if (out_idx) *out_idx = (int)idx;
if (!safe_read_ptr(qm + 0x755c, &b) || !b) return 0;
if (!safe_read_ptr(qm + 0x7560, &e) || e < b) return 0;
int cnt = (int)((e - b) / 0x50);
if (out_cnt) *out_cnt = cnt;
if ((int)idx < 0 || (int)idx >= cnt) return 0;
return b + (uintptr_t)idx * 0x50;
}
// Diagnostic: compare the by-objIdx entry (what the engine reads) vs
// the by-handle entry (what we used to write). Logs both + objIdx,
// count, by-handle index, cCreature+0x14 (gate bit 0x80000), +0xc.
static void dlg_probe(uintptr_t reb, int handle, const char* when) {
uintptr_t qm = engine::singletons::qm(reb), b = 0, e = 0;
uint32_t o48 = 0xFFFFFFFF, o4c = 0xFFFFFFFF; // by-objIdx entry
uint32_t h48 = 0xFFFFFFFF, h4c = 0xFFFFFFFF; // by-handle entry
uint32_t v14 = 0xFFFFFFFF, vc = 0xFFFFFFFF;
int hidx = -1, oidx = -1, cnt = -1;
uintptr_t c = npc_creature(handle);
if (c) { safe_read<uint32_t>(c + 0x0c, &vc);
safe_read<uint32_t>(c + 0x14, &v14); }
uintptr_t oe = dlg_entry_by_objidx(reb, handle, &oidx, &cnt);