fix: stabilize gameplay and IDE source writes

Trace shots against current map geometry instead of stale render depth. Preserve outline counters across volatile pixel calls and freeze gameplay on cinematic, transition and death ticks.

Clamp packed HP before narrowing and match legacy archetype rules. Publish IDE saves atomically; failed saves block file switches and builds. Drain compiler output while hidden.

Verify runtime/editor builds, 112 editor checks, 148 ASM checks and live preview parity. Record remaining stages in docs/STABILIZATION.md.
This commit is contained in:
Игорь Федосеев 2026-10-31 21:56:54 +03:00
parent c1da810bd5
commit f42679fe62
18 changed files with 594 additions and 70 deletions

67
docs/STABILIZATION.md Normal file
View file

@ -0,0 +1,67 @@
# Stabilization roadmap
Stages are delivered separately. Each completed stage is tested, committed and
pushed before work stops for confirmation. Editor and runtime UI redesign will
use an Unreal Engine 5-inspired visual style; stage 1 does not redesign UI.
## Stage 1 — correctness and data safety
Implemented:
- `DrawRectOutline` keeps loop counters in a callee-saved register, not the
volatile registers overwritten by `DrawPixel`. Nonpositive sizes draw nothing.
- `TraceShotWall` traverses current map cells independently of rendering. Distances
are 24.8 world units; closed doors, glass, portals and out-of-map cells block
shots. Open doors do not. Corner-adjacent walls block diagonal seam shots.
- Hitscan chooses the nearest live guard intersecting the forward billboard
hitbox. Half-width is 16 world units times authored scale (zero scale means
legacy 1.0), not a constant number of screen pixels.
- `IsGameplayBlocked` centralizes gameplay gating. `UpdatePlayer` latches blocked
ticks before processing cinematic state. AI, pickups and weapon timers freeze
during active/pending cutscenes and room fades. END/skip and instant transitions
cannot resume AI or fire stale input halfway through a tick. Death blocks player
movement, turning and firing; lethal damage stops the remaining entity pass.
- Packed entity metadata remains binary-compatible. Only `flags == 0` enables
legacy texture-based archetypes; any nonzero flags explicitly encode the low
type byte, including Prop. Health uses one byte: 1..255, zero = legacy 100 HP.
The C++ setter clamps before narrowing, and the inspector exposes 1..255.
- IDE source writes use a unique sibling temporary file, checked full write,
flush, close and atomic replacement. Failure preserves disk contents and dirty
buffer. Loading publishes a new document only after a complete valid text read.
File switches and builds stop on failed autosave. Saves target the file actually
opened, not a newly resolved fallback path.
- Compiler output is drained even with the IDE hidden. Process startup uses the
system command processor with quoting for project paths containing spaces.
### Verification
Windows commands:
```cmd
build.bat
editor\build_editor.bat
python tools\test_regressions.py
```
Results: editor **112/112**, ASM **148/148**. The suite also checks live x86/x64
preview IPC, DX11 pixel equality, cinematic trace equality and unchanged project
assets. New tests exercise production gameplay and IDE procedures, including
failed atomic replacement under a Windows sharing lock and hidden compiler output
exceeding pipe capacity. Interactive UI playtesting remains separate.
## Remaining stages
2. Predictable simulation: fixed 60 Hz ticks, fractional movement, normalized
input, complete collision shape, current-room restart/reset policy. Extend
gameplay gating into explicit states and separate cinematic camera ownership.
3. Honest editor/gameplay: complete undo, keys/switches and supported interactions;
UE5-inspired editor/runtime UI redesign.
4. Project lifecycle and validated, safe standalone export.
5. Measurements and targeted renderer/editor optimizations.
6. Finished multi-room demo game and release verification.
7. Data-driven extensions and subsequent rendering features.
Stage 1 intentionally does not claim that all audit findings are fixed: restart
semantics, fixed timing, collision/AI visibility, full undo and exporter safety
remain assigned to subsequent stages. Existing map and texture assets are not
migrated or rewritten.

Binary file not shown.

View file

@ -2102,18 +2102,18 @@ void EditorApp::RenderInspector() {
if (ImGui::Combo("Тип сущности", &archType, s_ArchetypeNames, 6)) {
PushUndo();
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
}
if (archType == ENTITY_ARCHETYPE_GUARD) {
if (ImGui::SliderInt("Здоровье (HP)", &hp, 10, 500)) {
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
if (ImGui::SliderInt("Здоровье (HP)", &hp, 1, 255)) {
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
}
if (ImGui::SliderInt("Скорость", &spd, 1, 8)) {
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
}
if (ImGui::SliderInt("Радиус агрессии (ячейки)", &agg, 1, 15)) {
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
}
} else if (archType == ENTITY_ARCHETYPE_MEDKIT) {
ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Восстанавливает +25 HP игроку при подборе");

View file

@ -93,6 +93,7 @@ static const int g_ScriptTemplateCount = sizeof(g_ScriptTemplates) / sizeof(g_Sc
struct CodeEditorState {
int currentFile = 0;
std::string fileContent;
fs::path loadedPath;
std::vector<char> textBuffer;
bool isModified = false;
std::string statusText = "Готов к редактированию кода MASM32";
@ -120,50 +121,64 @@ struct CodeEditorState {
void LoadFile(int fileIndex, const std::string& projectRoot) {
if (fileIndex < 0 || fileIndex >= g_CodeFileCount) return;
currentFile = fileIndex;
if (isModified && !SaveFile(projectRoot)) return;
fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[fileIndex].relativePath;
if (!fs::exists(fullPath)) {
fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[fileIndex].relativePath;
}
HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (hFile != INVALID_HANDLE_VALUE) {
DWORD fileSize = GetFileSize(hFile, nullptr);
if (fileSize != INVALID_FILE_SIZE) {
EnsureBufferSize(fileSize);
DWORD read = 0;
ReadFile(hFile, textBuffer.data(), fileSize, &read, nullptr);
textBuffer[read] = '\0';
fileContent = std::string(textBuffer.data(), read);
isModified = false;
statusText = "Открыт файл: " + g_ProjectCodeFiles[fileIndex].displayName;
}
CloseHandle(hFile);
} else {
fileContent.clear();
EnsureBufferSize(1024);
textBuffer[0] = '\0';
isModified = false;
if (hFile == INVALID_HANDLE_VALUE) {
statusText = "Не удалось открыть файл: " + fullPath.string();
return;
}
LARGE_INTEGER size{};
bool ok = GetFileSizeEx(hFile, &size) && size.QuadPart >= 0 && size.QuadPart <= 16 * 1024 * 1024;
std::vector<char> incoming(ok ? static_cast<size_t>(size.QuadPart) + 1 : 1, 0);
DWORD read = 0;
if (ok) ok = ReadFile(hFile, incoming.data(), static_cast<DWORD>(size.QuadPart), &read, nullptr) &&
read == static_cast<DWORD>(size.QuadPart);
CloseHandle(hFile);
if (!ok || std::memchr(incoming.data(), '\0', read)) {
statusText = "Ошибка чтения исходного файла: " + fullPath.string();
return;
}
// Publish a document only after a complete read. Failed loads keep the old buffer.
currentFile = fileIndex;
loadedPath = fs::absolute(fullPath);
fileContent.assign(incoming.data(), read);
textBuffer = std::move(incoming);
EnsureBufferSize(read);
isModified = false;
statusText = "Открыт файл: " + g_ProjectCodeFiles[fileIndex].displayName;
}
bool SaveFile(const std::string& projectRoot) {
if (currentFile < 0 || currentFile >= g_CodeFileCount) return false;
fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[currentFile].relativePath;
if (!fs::exists(fullPath)) {
fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[currentFile].relativePath;
if (currentFile < 0 || currentFile >= g_CodeFileCount || loadedPath.empty() || textBuffer.empty()) {
statusText = "Сначала откройте исходный файл.";
return false;
}
HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
const auto fullPath = loadedPath;
static unsigned serial = 0;
const auto temporary = fullPath.string() + ".tmp." + std::to_string(GetCurrentProcessId()) + "." + std::to_string(++serial);
HANDLE hFile = CreateFileA(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
if (hFile == INVALID_HANDLE_VALUE) {
statusText = "Ошибка сохранения: доступ запрещен (" + fullPath.string() + ")";
return false;
}
DWORD len = static_cast<DWORD>(std::strlen(textBuffer.data()));
DWORD written = 0;
WriteFile(hFile, textBuffer.data(), len, &written, nullptr);
CloseHandle(hFile);
bool ok = WriteFile(hFile, textBuffer.data(), len, &written, nullptr) && written == len;
if (ok) ok = FlushFileBuffers(hFile) != FALSE;
if (!CloseHandle(hFile)) ok = false;
if (ok) ok = MoveFileExA(temporary.c_str(), fullPath.string().c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != FALSE;
if (!ok) {
DeleteFileA(temporary.c_str());
statusText = "Ошибка сохранения; исходный файл сохранен: " + fullPath.string();
return false;
}
fileContent.assign(textBuffer.data(), len);
isModified = false;
statusText = "Файл сохранен: " + g_ProjectCodeFiles[currentFile].displayName;
return true;
@ -173,7 +188,7 @@ struct CodeEditorState {
if (isBuilding) return;
// Auto-save current file before building
SaveFile(projectRoot);
if (!SaveFile(projectRoot)) return;
buildLog.clear();
buildErrors.clear();
@ -214,9 +229,12 @@ struct CodeEditorState {
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi{};
std::string cmd = "cmd.exe /c \"" + buildBat.string() + "\"";
char commandProcessor[MAX_PATH]{};
GetSystemDirectoryA(commandProcessor, MAX_PATH);
const auto cmdPath = fs::path(commandProcessor) / "cmd.exe";
std::string cmd = "\"" + cmdPath.string() + "\" /d /s /c \"\"" + buildBat.string() + "\"\"";
if (CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, root.string().c_str(), &si, &pi)) {
if (CreateProcessA(cmdPath.string().c_str(), cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, root.string().c_str(), &si, &pi)) {
CloseHandle(pi.hThread);
hBuildProcess = pi.hProcess;
isBuilding = true;
@ -225,6 +243,8 @@ struct CodeEditorState {
buildLog = "[ERROR] Не удалось запустить процесс сборки.\n";
hasBuildResult = true;
buildExitCode = 1;
CloseHandle(hReadPipe);
hReadPipe = nullptr;
}
CloseHandle(hWritePipe);
}
@ -316,9 +336,9 @@ struct CodeEditorState {
static CodeEditorState g_CodeEditor;
void EditorApp::RenderCodeEditorWindow() {
if (!showCodeEditorModal) return;
// Drain compiler output even when the IDE is hidden; a full pipe blocks ml/link.
g_CodeEditor.PollBuild();
if (!showCodeEditorModal) return;
ImGui::SetNextWindowSize(ImVec2(900, 650), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Редактор кода и скриптов MASM32 - EblanStudio IDE", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) {
@ -408,9 +428,6 @@ void EditorApp::RenderCodeEditorWindow() {
for (int i = 0; i < g_CodeFileCount; ++i) {
bool isSelected = (g_CodeEditor.currentFile == i);
if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) {
if (g_CodeEditor.isModified) {
g_CodeEditor.SaveFile(root);
}
g_CodeEditor.LoadFile(i, root);
}
}
@ -512,7 +529,6 @@ void EditorApp::RenderCodeEditorWindow() {
for (int f = 0; f < g_CodeFileCount; ++f) {
if (err.file.find(g_ProjectCodeFiles[f].relativePath) != std::string::npos ||
g_ProjectCodeFiles[f].relativePath.find(err.file) != std::string::npos) {
if (g_CodeEditor.isModified) g_CodeEditor.SaveFile(root);
g_CodeEditor.LoadFile(f, root);
break;
}

View file

@ -103,7 +103,7 @@ struct SpriteEntry {
inline uint8_t GetSpriteEntityType(const SpriteEntry& s) {
uint8_t t = static_cast<uint8_t>(s.flags & 0xFF);
if (t == 0) {
if (s.flags == 0) {
if (s.textureId == 13) return ENTITY_ARCHETYPE_GUARD;
if (s.textureId == 14) return ENTITY_ARCHETYPE_MEDKIT;
if (s.textureId == 15) return ENTITY_ARCHETYPE_AMMO;
@ -122,7 +122,10 @@ inline uint8_t GetSpriteAggro(const SpriteEntry& s) {
uint8_t agg = static_cast<uint8_t>((s.flags >> 24) & 0xFF);
return (agg == 0) ? 6 : agg;
}
inline void SetSpriteEntityData(SpriteEntry& s, uint8_t type, uint8_t health, uint8_t speed, uint8_t aggro) {
// Clamp before narrowing: callers must not silently wrap values such as 256 HP.
// Zero remains the legacy default encoding for health, speed and aggro.
inline void SetSpriteEntityData(SpriteEntry& s, uint8_t type, int health, uint8_t speed, uint8_t aggro) {
health = health < 0 ? 0 : (health > 255 ? 255 : health);
s.flags = static_cast<uint32_t>(type) |
(static_cast<uint32_t>(health) << 8) |
(static_cast<uint32_t>(speed) << 16) |

Binary file not shown.

View file

@ -10,6 +10,130 @@ DrawRectOutline PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD
DrawNumber8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD
; Trace the current world along the player's forward direction. EAX = first
; blocking boundary in 24.8 world units. Glass blocks shots (unlike rendering).
; No framebuffer/depth-buffer dependencies. Out-of-map cells are solid.
TraceShotWall PROC uses ebx ecx edx esi edi
LOCAL cellX:DWORD, cellY:DWORD, stepX:DWORD, stepY:DWORD
LOCAL deltaX:DWORD, deltaY:DWORD, sideX:DWORD, sideY:DWORD
LOCAL hitDistance:DWORD, remaining:DWORD
mov eax, playerX
sar eax, 6
mov cellX, eax
mov eax, playerY
sar eax, 6
mov cellY, eax
invoke GetMapCell, cellX, cellY
invoke IsSolidCell, eax
test eax, eax
jnz shot_wall_at_origin
mov eax, playerAngle
and eax, ANGLE_MASK
mov esi, [tableCos+eax*4]
mov edi, [tableSin+eax*4]
mov deltaX, 3FFFFFFFh
mov sideX, 3FFFFFFFh
mov stepX, 1
test esi, esi
jz shot_setup_y
mov eax, esi
cdq
xor eax, edx
sub eax, edx
mov ecx, eax
mov eax, (64 shl 24)
xor edx, edx
div ecx
mov deltaX, eax
mov eax, playerX
and eax, 63
test esi, esi
js shot_negative_x
neg eax
add eax, 64
jmp shot_side_x
shot_negative_x:
mov stepX, -1
shot_side_x:
imul deltaX
shrd eax, edx, 6
mov sideX, eax
shot_setup_y:
mov deltaY, 3FFFFFFFh
mov sideY, 3FFFFFFFh
mov stepY, 1
test edi, edi
jz shot_traverse_start
mov eax, edi
cdq
xor eax, edx
sub eax, edx
mov ecx, eax
mov eax, (64 shl 24)
xor edx, edx
div ecx
mov deltaY, eax
mov eax, playerY
and eax, 63
test edi, edi
js shot_negative_y
neg eax
add eax, 64
jmp shot_side_y
shot_negative_y:
mov stepY, -1
shot_side_y:
imul deltaY
shrd eax, edx, 6
mov sideY, eax
shot_traverse_start:
mov remaining, MAP_W+MAP_H+2
shot_traverse:
mov eax, sideX
cmp eax, sideY
jb shot_step_x
jne shot_step_y
; At a corner also test the X-adjacent cell: no shots through wall seams.
mov ebx, cellX
add ebx, stepX
invoke GetMapCell, ebx, cellY
invoke IsSolidCell, eax
test eax, eax
jz shot_step_y
mov eax, sideX
ret
shot_step_x:
mov hitDistance, eax
add eax, deltaX
mov sideX, eax
mov eax, stepX
add cellX, eax
jmp shot_test_cell
shot_step_y:
mov eax, sideY
mov hitDistance, eax
add eax, deltaY
mov sideY, eax
mov eax, stepY
add cellY, eax
shot_test_cell:
invoke GetMapCell, cellX, cellY
invoke IsSolidCell, eax
test eax, eax
jnz shot_wall_found
dec remaining
jnz shot_traverse
; Defensive bound: a valid ray exits a 32x32 map within 64 crossings.
shot_wall_found:
mov eax, hitDistance
ret
shot_wall_at_origin:
xor eax, eax
ret
TraceShotWall ENDP
; ------------------------------------------------------------------------------
; UpdateCombat: Process weapon firing, hitscan raycasting, cooldowns & damage
; ------------------------------------------------------------------------------
@ -18,7 +142,11 @@ UpdateCombat PROC uses ebx ecx edx esi edi
LOCAL i:DWORD, closestIdx:DWORD, closestDist:DWORD
LOCAL sprX:DWORD, sprY:DWORD
LOCAL transX:DWORD, transY:DWORD
LOCAL sprScreenX:DWORD, wallDepth:DWORD
LOCAL targetHalfWidth:DWORD, wallDepth:DWORD
call IsGameplayBlocked
test eax, eax
jnz combat_done
; 1. Decrement cooldowns
cmp playerAttackCooldown, 0
@ -63,8 +191,8 @@ UpdateCombat PROC uses ebx ecx edx esi edi
mov edx, [tableSin + eax*4]
mov camSin, edx
; Center wall depth from depthBuffer[160]
mov eax, [depthBuffer + SCREEN_HALF_W * 4]
; Query current geometry, never the previous rendered frame.
call TraceShotWall
mov wallDepth, eax
mov closestIdx, -1
@ -117,8 +245,9 @@ hitscan_loop:
cmp transY, 16
jle next_hitscan_target
; Must be closer than the wall at center screen
; Compare in 24.8 so a target on a wall boundary is not hit through it.
mov eax, transY
shl eax, 8
cmp eax, wallDepth
jge next_hitscan_target
@ -133,20 +262,21 @@ hitscan_loop:
add ecx, eax
mov transX, ecx
; sprScreenX = (transX * PROJ_DIST) / transY + SCREEN_HALF_W
; World-space billboard hitbox: 32 units wide at scale 1.0, scaled with
; the authored sprite, never with screen distance. Zero means legacy 1.0.
mov eax, [ebx].SpriteStruct.scale
test eax, eax
jnz @F
mov eax, FIXED_ONE
@@:
imul eax, 16
sar eax, 16
mov targetHalfWidth, eax
mov eax, transX
imul eax, PROJ_DIST
cdq
idiv transY
add eax, SCREEN_HALF_W
mov sprScreenX, eax
; Crosshair hitbox: within 22 pixels of center (SCREEN_HALF_W = 160)
sub eax, SCREEN_HALF_W
cdq
xor eax, edx
sub eax, edx ; abs(sprScreenX - 160)
cmp eax, 22
sub eax, edx
cmp eax, targetHalfWidth
jg next_hitscan_target
; Check if closer than previous target

View file

@ -17,6 +17,9 @@ hEngineInstance dd 0
isRunning dd 1
; High Precision Timing
; Latched by UpdatePlayer: scene/end/skip/instant-transition ticks never run AI
; or combat, even if they return control partway through the tick.
gameplayTickBlocked dd 0
targetFrameTimeMs dd 16 ; ~60 FPS (16.6 ms)
lastTickCount dd 0
fpsCounter dd 0

View file

@ -142,6 +142,9 @@ UpdateEntities PROC uses ebx ecx edx esi edi
ret
entities_active:
call IsGameplayBlocked
test eax, eax
jnz entities_done
mov i, 0
entity_loop:
@ -376,6 +379,8 @@ check_guard_attack:
test eax, eax
jnz next_entity
mov isPlayerDead, 1
; Nothing else may collect items or attack after a lethal hit this tick.
jmp entities_done
next_entity:
inc i

View file

@ -376,6 +376,9 @@ SwitchSublevel PROC uses esi edi, pszNextMap:DWORD, spawnX:DWORD, spawnY:DWORD,
cmp fadeState, 0
jne switch_busy
; Even an instant room switch consumes this tick. AI/combat must not use
; pre-transition input in the newly loaded room.
mov gameplayTickBlocked, 1
mov esi, pszNextMap
lea edi, pendingMapPath
mov ecx, 32

View file

@ -4,6 +4,17 @@
.code
; Shared gameplay gate. Returns EAX != 0 when simulation must be frozen.
; UpdatePlayer owns the per-tick latch; renderer never changes this decision.
IsGameplayBlocked PROC
mov eax, gameplayTickBlocked
or eax, isPlayerDead
or eax, isCutsceneActive
or eax, cutscenePending
or eax, fadeState
ret
IsGameplayBlocked ENDP
; ------------------------------------------------------------------------------
; UpdatePlayer: Updates rotation, computes movement vector & handles collisions
; ------------------------------------------------------------------------------
@ -13,6 +24,9 @@ UpdatePlayer PROC uses ebx ecx edx esi edi
LOCAL nextX:DWORD, nextY:DWORD
LOCAL checkGridX:DWORD, checkGridY:DWORD
mov gameplayTickBlocked, 1
cmp isPlayerDead, 0
jne update_player_done
; Freeze player movement and inputs during level transitions
cmp fadeState, 0
jne update_player_done
@ -37,6 +51,7 @@ scene_update_tick:
call UpdateCutscene
ret
normal_player_update:
mov gameplayTickBlocked, 0
mov moveX, 0
mov moveY, 0
@ -62,6 +77,8 @@ check_action:
call InteractDoor
cmp fadeState, 0
jne update_player_done
cmp gameplayTickBlocked, 0
jne update_player_done
; 2. Determine Movement Speed
check_movement:

View file

@ -90,9 +90,13 @@ DrawRect ENDP
; ------------------------------------------------------------------------------
; DrawRectOutline: Draws a 1-pixel rectangle outline
; ------------------------------------------------------------------------------
DrawRectOutline PROC x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD
DrawRectOutline PROC uses ebx, x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD
LOCAL x2:DWORD, y2:DWORD
cmp w, 0
jle outline_done
cmp h, 0
jle outline_done
mov eax, x
add eax, w
dec eax
@ -104,26 +108,28 @@ DrawRectOutline PROC x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD
mov y2, eax
; Top & Bottom horizontal lines
mov ecx, x
; DrawPixel may destroy EAX/ECX/EDX. Keep counters in callee-saved EBX.
mov ebx, x
@@:
cmp ecx, x2
cmp ebx, x2
jg @F
invoke DrawPixel, ecx, y, color
invoke DrawPixel, ecx, y2, color
inc ecx
invoke DrawPixel, ebx, y, color
invoke DrawPixel, ebx, y2, color
inc ebx
jmp @B
@@:
; Left & Right vertical lines
mov edx, y
mov ebx, y
@@:
cmp edx, y2
cmp ebx, y2
jg @F
invoke DrawPixel, x, edx, color
invoke DrawPixel, x2, edx, color
inc edx
invoke DrawPixel, x, ebx, color
invoke DrawPixel, x2, ebx, color
inc ebx
jmp @B
@@:
outline_done:
ret
DrawRectOutline ENDP

BIN
main.obj

Binary file not shown.

View file

@ -23,6 +23,7 @@ static std::vector<uint8_t> Bytes(const fs::path& path) {
#include "cutscene_editor_checks.inl"
#include "viewport_editor_checks.inl"
#include "stability_editor_checks.inl"
int main(int argc, char** argv) {
EditorApp app;
@ -152,6 +153,7 @@ int main(int argc, char** argv) {
app.CleanupPlaytest();
}
if(argc>2) RunViewportChecks(app,argv[2]);
RunStabilityEditorChecks();
std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n";
return failures ? 1 : 0;
}

View file

@ -126,6 +126,7 @@ state_changed:
StateUnchanged ENDP
include tests/cutscene_engine_checks.inc
include tests/stability_engine_checks.inc
AuditMain PROC
call InitMathTables
@ -363,6 +364,7 @@ flat_depth_ok:
invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
call RunCutsceneChecks
call RunStabilityChecks
mov eax, resultCount
inc eax
shl eax, 2

View file

@ -0,0 +1,66 @@
static void RunStabilityEditorChecks() {
SpriteEntry sprite{};
sprite.textureId = 13;
Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_GUARD, "zero flags retain legacy guard fallback");
sprite.flags = 0x06026400;
Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_PROP, "explicit Prop matches ASM despite guard texture");
for (int health : {1, 100, 255, 256, 300, 500}) {
SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_GUARD, health, 2, 6);
Check(GetSpriteHealth(sprite) == (health > 255 ? 255 : health), "health setter clamps before byte packing");
}
const auto root = fs::absolute("code fixture");
fs::create_directories(root / "include");
const auto first = root / g_ProjectCodeFiles[0].relativePath;
const auto second = root / g_ProjectCodeFiles[1].relativePath;
{ std::ofstream out(first); out << "original"; }
{ std::ofstream out(second); out << "second"; }
CodeEditorState code;
code.LoadFile(0, root.string());
Check(std::string(code.textBuffer.data()) == "original", "IDE opens actual source file");
strcpy_s(code.textBuffer.data(), code.textBuffer.size(), "unsaved source");
code.isModified = true;
HANDLE locked = CreateFileA(first.string().c_str(), GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
Check(locked != INVALID_HANDLE_VALUE, "IDE failure fixture locks destination against replacement");
Check(!code.SaveFile(root.string()) && code.isModified &&
Bytes(first) == std::vector<uint8_t>({'o','r','i','g','i','n','a','l'}),
"failed IDE replacement preserves disk and modified buffer");
code.LoadFile(1, root.string());
Check(code.currentFile == 0 && code.isModified && std::string(code.textBuffer.data()) == "unsaved source",
"IDE refuses file switch after failed autosave");
code.StartBuild(root.string());
Check(!code.isBuilding && code.isModified, "IDE refuses build after failed save");
CloseHandle(locked);
Check(code.SaveFile(root.string()) && !code.isModified &&
Bytes(first) == std::vector<uint8_t>({'u','n','s','a','v','e','d',' ','s','o','u','r','c','e'}),
"IDE publishes complete source atomically");
code.LoadFile(1, root.string());
Check(code.currentFile == 1 && std::string(code.textBuffer.data()) == "second", "IDE switches after successful save");
fs::remove(first);
code.LoadFile(0, root.string());
Check(code.currentFile == 1 && std::string(code.textBuffer.data()) == "second", "failed IDE load preserves current document");
Check(std::distance(fs::directory_iterator(root / "include"), fs::directory_iterator{}) == 1,
"failed IDE saves leave no temporary source files");
{ std::ofstream out(root / "build.bat");
out << "@echo off\nfor /L %%i in (1,1,4000) do echo compiler-output-%%i\nexit /b 7\n"; }
g_CodeEditor.LoadFile(1, root.string());
g_CodeEditor.StartBuild(root.string());
EditorApp hidden;
hidden.showCodeEditorModal = false;
const auto deadline = GetTickCount64() + 10000;
while (g_CodeEditor.isBuilding && GetTickCount64() < deadline) {
hidden.RenderCodeEditorWindow();
if (g_CodeEditor.hBuildProcess) WaitForSingleObject(g_CodeEditor.hBuildProcess, 1);
}
const bool finished = !g_CodeEditor.isBuilding;
if (!finished && g_CodeEditor.hBuildProcess) {
TerminateProcess(g_CodeEditor.hBuildProcess, 99);
WaitForSingleObject(g_CodeEditor.hBuildProcess, 1000);
g_CodeEditor.PollBuild();
}
Check(finished && g_CodeEditor.buildExitCode == 7 &&
g_CodeEditor.buildLog.find("compiler-output-4000") != std::string::npos,
"hidden IDE drains output larger than pipe capacity and retains exit code");
}

View file

@ -0,0 +1,202 @@
; Real gameplay procedures, deliberately without Render3DScene between shots.
.code
ResetCombatFixture PROC uses eax ecx edi
mov edi, OFFSET gameMap
mov ecx, MAP_SIZE
xor eax, eax
rep stosb
mov fadeState, 0
mov isCutsceneActive, 0
mov cutscenePending, 0
mov isPlayerDead, 0
mov playerHealth, 100
mov playerAmmo, 50
mov playerScore, 0
mov playerAttackCooldown, 0
mov playerFlashTimer, 0
mov playerHitFlash, 0
mov playerX, 160
mov playerY, 160
mov playerAngle, 0
mov keyForward, 0
mov keyBackward, 0
mov keyStrafeLeft, 0
mov keyStrafeRight, 0
mov keyTurnLeft, 0
mov keyTurnRight, 0
mov keyCutscene, 0
mov keySkipScene, 0
mov keyAction, 0
mov keyRestart, 0
mov keyAttack, 1
mov transitionCount, 0
mov spriteCount, 1
mov [spriteList].SpriteStruct.x, 288
mov [spriteList].SpriteStruct.y, 160
mov [spriteList].SpriteStruct.textureId, 13
mov [spriteList].SpriteStruct.scale, FIXED_ONE
mov [spriteList].SpriteStruct.flags, 0
call InitEntities
call UpdatePlayer
ret
ResetCombatFixture ENDP
RunStabilityChecks PROC
mov edi, OFFSET screenBuffer
mov ecx, FRAME_BUFFER_SIZE/4
mov eax, 00123456h
rep stosd
invoke DrawRectOutline, 68, 42, 60, 8, COLOR_WHITE
CHECK DWORD PTR [screenBuffer+42*SCREEN_PITCH+127*4], COLOR_WHITE ; white outline reaches top right
CHECK DWORD PTR [screenBuffer+49*SCREEN_PITCH+100*4], COLOR_WHITE ; white outline draws bottom edge
CHECK DWORD PTR [screenBuffer+45*SCREEN_PITCH+68*4], COLOR_WHITE ; white outline draws left edge
CHECK DWORD PTR [screenBuffer+45*SCREEN_PITCH+100*4], 00123456h ; outline keeps interior unchanged
invoke DrawRectOutline, 68, 42, 60, 8, 0
CHECK DWORD PTR [screenBuffer+49*SCREEN_PITCH+100*4], 0 ; black outline terminates and draws bottom
invoke DrawRectOutline, -2, -2, 5, 5, COLOR_GREEN
CHECK DWORD PTR [screenBuffer+2*SCREEN_PITCH], COLOR_GREEN ; outline clips offscreen coordinates
invoke DrawRectOutline, 100, 45, 0, 3, COLOR_WHITE
CHECK DWORD PTR [screenBuffer+45*SCREEN_PITCH+100*4], 00123456h ; zero width outline draws nothing
call ResetCombatFixture
mov DWORD PTR [depthBuffer+160*4], 0
call UpdateCombat
CHECK entityHealth, 65 ; hitscan works without a rendered depth buffer
CHECK playerAmmo, 49 ; one shot consumes one round
mov playerAttackCooldown, 0
mov byte ptr [gameMap+2*MAP_W+3], CELL_BRICK
mov DWORD PTR [depthBuffer+160*4], 999999
call UpdateCombat
CHECK entityHealth, 65 ; newly placed wall blocks a shot despite stale depth
mov playerAttackCooldown, 0
mov byte ptr [gameMap+2*MAP_W+3], CELL_DOOR_OPEN
call UpdateCombat
CHECK entityHealth, 30 ; newly opened door permits a shot without rendering
mov playerAttackCooldown, 0
mov byte ptr [gameMap+2*MAP_W+3], CELL_GLASS
call UpdateCombat
CHECK entityHealth, 30 ; solid glass blocks gameplay shots
call ResetCombatFixture
mov [spriteList].SpriteStruct.x, 800
mov [spriteList].SpriteStruct.y, 180
mov DWORD PTR [depthBuffer+160*4], 999999
call UpdateCombat
CHECK entityHealth, 100 ; distant target does not acquire a larger world hitbox
call ResetCombatFixture
mov [spriteList].SpriteStruct.x, 160
mov [spriteList].SpriteStruct.y, 288
mov playerAngle, ANGLE_90
mov DWORD PTR [depthBuffer+160*4], 0
call UpdateCombat
CHECK entityHealth, 65 ; cardinal turn uses current camera instead of previous frame
call ResetCombatFixture
mov playerAngle, ANGLE_180
mov [spriteList].SpriteStruct.x, 32
call UpdateCombat
CHECK entityHealth, 65 ; negative cardinal shot traverses the current map
call ResetCombatFixture
mov playerAngle, ANGLE_45
mov [spriteList].SpriteStruct.y, 288
call UpdateCombat
CHECK entityHealth, 65 ; diagonal shot reaches unobstructed target
mov playerAttackCooldown, 0
mov byte ptr [gameMap+2*MAP_W+3], CELL_BRICK
call UpdateCombat
CHECK entityHealth, 65 ; diagonal ray cannot pass through a wall corner
call ResetCombatFixture
mov spriteCount, 2
mov esi, OFFSET spriteList+sizeof SpriteStruct
mov [esi].SpriteStruct.x, 224
mov [esi].SpriteStruct.y, 160
mov [esi].SpriteStruct.textureId, 13
mov [esi].SpriteStruct.scale, FIXED_ONE
mov [esi].SpriteStruct.flags, 0
call InitEntities
call UpdateCombat
CHECK entityHealth, 100 ; hitscan does not damage farther overlapping guard
CHECK entityHealth[4], 65 ; hitscan selects nearest live guard independent of insertion order
call ResetCombatFixture
mov [spriteList].SpriteStruct.y, 180
mov [spriteList].SpriteStruct.scale, 2*FIXED_ONE
call UpdateCombat
CHECK entityHealth, 65 ; authored sprite scale affects world-space hitbox
mov playerAttackCooldown, 0
mov [spriteList].SpriteStruct.scale, 0
call UpdateCombat
CHECK entityHealth, 65 ; zero sprite scale retains legacy one-unit scale
call ResetCombatFixture
mov isCutsceneActive, 1
mov playerAttackCooldown, 4
call UpdateEntities
call UpdateCombat
CHECK [spriteList].SpriteStruct.x, 288 ; cinematic camera cannot attract guards
CHECK playerAmmo, 50 ; cinematic camera cannot fire
CHECK playerAttackCooldown, 4 ; cinematic pauses weapon timers
mov isCutsceneActive, 0
mov fadeState, 1
call UpdateEntities
call UpdateCombat
CHECK [spriteList].SpriteStruct.x, 288 ; transition freezes guards
CHECK playerAmmo, 50 ; transition blocks firing
mov fadeState, 0
mov cutscenePending, 1
call UpdateEntities
call UpdateCombat
CHECK [spriteList].SpriteStruct.x, 288 ; pending autoplay blocks gameplay before first scene tick
CHECK playerAmmo, 50 ; pending autoplay blocks firing
call ResetCombatFixture
mov isCutsceneActive, 1
mov keySkipScene, 1
call UpdatePlayer
call UpdateEntities
call UpdateCombat
CHECK isCutsceneActive, 0 ; skip stops cinematic playback
CHECK [spriteList].SpriteStruct.x, 288 ; skip tick does not resume guards midway through tick
CHECK playerAmmo, 50 ; skip tick cannot fire stale scene input
call ResetCombatFixture
invoke SwitchSublevel, OFFSET arenaPath, 2, 2, 0, 0
call UpdateCombat
CHECK playerAmmo, 50 ; instant transition cannot fire into newly loaded room
call ResetCombatFixture
mov [spriteList].SpriteStruct.x, 160
mov [spriteList].SpriteStruct.textureId, 14
call InitEntities
mov playerHealth, 50
mov isCutsceneActive, 1
call UpdateEntities
CHECK playerHealth, 50 ; cinematic camera cannot collect a medkit
CHECK [spriteList].SpriteStruct.textureId, 14 ; cinematic leaves pickup active
mov isCutsceneActive, 0
call UpdateEntities
CHECK playerHealth, 75 ; normal gameplay collects medkit
call ResetCombatFixture
mov isPlayerDead, 1
mov keyForward, 1
mov keyTurnRight, 1
call UpdatePlayer
call UpdateCombat
CHECK playerX, 160 ; death blocks movement
CHECK playerAngle, 0 ; death blocks turning
CHECK playerAmmo, 50 ; death blocks firing
mov isPlayerDead, 0
mov keyForward, 0
mov keyTurnRight, 0
call UpdatePlayer
mov [spriteList].SpriteStruct.flags, 0602FF01h
call InitEntities
CHECK entityHealth, 255 ; packed guard health preserves maximum byte
mov [spriteList].SpriteStruct.flags, 06026400h
call InitEntities
CHECK entityType, ENTITY_TYPE_PROP ; nonzero packed Prop never falls back to texture
mov [spriteList].SpriteStruct.flags, 0
call InitEntities
CHECK entityType, ENTITY_TYPE_GUARD ; zero flags keep legacy texture fallback
ret
RunStabilityChecks ENDP

View file

@ -133,7 +133,7 @@ def main():
(work / "project_paths.inc").write_text(
f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n'
f'projectPackPath db "{ROOT / "assets/textures/texturepack.bin"}",0\n.code\n')
masm_candidates = [Path(p) for p in ("D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")]
masm_candidates = [Path(p) for p in ("E:/MASM32/masm32/bin/ml.exe", "D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")]
masm_candidates += list(Path("C:/Program Files (x86)/Microsoft Visual Studio").glob("*/BuildTools/VC/Tools/MSVC/*/bin/Hostx64/x86/ml.exe"))
ml = next((p for p in masm_candidates if p.is_file()), None)
if not ml:
@ -148,7 +148,9 @@ def main():
f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"),
"kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work)
run([str(work / "engine_tests.exe"), "pace map.dat"], work)
source = (ROOT / "tests/engine_regressions.asm").read_text() + (ROOT / "tests/cutscene_engine_checks.inc").read_text()
source = (ROOT / "tests/engine_regressions.asm").read_text() + "".join(
(ROOT / "tests" / name).read_text() for name in
("cutscene_engine_checks.inc", "stability_engine_checks.inc"))
names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M)
raw = (work / "engine_results.bin").read_bytes()
values = struct.unpack(f"<{len(raw)//4}I", raw)