diff --git a/.gitattributes b/.gitattributes index 945cc9e..7425389 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,4 @@ sound/tests/audition/*.H text eol=crlf examples/doebel/DOEBEL.VGM binary examples/doebel/NOTES.csv binary examples/smsboot/SMSBOOT.ZIP binary +examples/engine/ENGINE.ZIP binary diff --git a/.github/workflows/portable-midi.yml b/.github/workflows/portable-midi.yml index 10f0aed..b286ef1 100644 --- a/.github/workflows/portable-midi.yml +++ b/.github/workflows/portable-midi.yml @@ -13,3 +13,5 @@ jobs: run: python3 sound/tests/run.py --cc gcc - name: Qualified optional DOS example package run: python3 tools/check_sms_package.py + - name: Qualified optional engine DOS package + run: python3 tools/check_engine_package.py diff --git a/.gitignore b/.gitignore index ae34f3e..e32ad4f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ __pycache__/ *.img *.zip !examples/smsboot/SMSBOOT.ZIP +!examples/engine/ENGINE.ZIP diff --git a/README.md b/README.md index fac03a4..49566c6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Windows 3.0 USER/GDI project named libwintandy. | Modules | Contract | | --- | --- | +| `lt_engine.h` (optional) | Caller-owned objects, Awake/Start/FixedUpdate/Update/LateUpdate, bounded foreground catch-up, AABB/tile collision and camera. Rendering-independent simulation; [engine guide and DOS demos](docs/ENGINE.md). | +| `lt_engine_view.h`, `lt_engine_dos.h` (optional) | Explicit native Tandy 160x200/320x200 packed layouts and 40/80-column text; optional mode/page/cursor setup/restore. No timer or sound ownership. | | `lt_sound.h`, `lt_chip.c`, DOS sink | Caller owns hardware exclusion and scheduling. Owner tokens track channels; they do not arbitrate against other applications. Stop mutes before unclaim. | | `lt_gfx.h`, `lt_gfx.c` | Caller supplies a valid 32 KiB packed four-bank mode-9 aperture. No mode switch, allocation or resident service in the raster core. | | `lt_dos_app.h` (optional) | Foreground convenience setup for mode 9, the direct video aperture, BIOS input state and the default Tandy sound port. Saves/restores video page and cursor; no allocation or resident service. | diff --git a/adapters/lt_engine_dos.c b/adapters/lt_engine_dos.c new file mode 100644 index 0000000..56158b2 --- /dev/null +++ b/adapters/lt_engine_dos.c @@ -0,0 +1,45 @@ +/* SPDX-License-Identifier: MIT */ +#if !defined(__WATCOMC__) || !defined(__I86__) +#error lt_engine_dos requires 16-bit Open Watcom +#endif +#include +#include +#include "lt_engine_dos.h" +static void bios(unsigned ax, unsigned bx, unsigned cx, unsigned dx) +{ + union REGS r; + memset(&r, 0, sizeof(r)); + r.x.ax = ax; r.x.bx = bx; r.x.cx = cx; r.x.dx = dx; + int86(0x10, &r, &r); +} +void lte_dos_video_close(lte_dos_video *v) +{ + if (!v || !v->active) return; + bios(v->mode, 0, 0, 0); + bios(0x0500U | v->page, 0, 0, 0); + bios(0x0100U, 0, ((unsigned)v->cursor_start << 8) | v->cursor_end, 0); + bios(0x0200U, (unsigned)v->page << 8, 0, + ((unsigned)v->row << 8) | v->col); + v->active = 0; v->view.pixels = 0; +} +int lte_dos_video_open(lte_dos_video *v, unsigned mode) +{ + union REGS r; + lte_view view; + if (!v || v->active || !lte_view_bind(&view, mode, + (unsigned char LTE_FAR *)MK_FP(0xb800, 0), 32768U)) return 0; + memset(&r, 0, sizeof(r)); r.h.ah = 15; + int86(0x10, &r, &r); v->mode = r.h.al; v->page = r.h.bh; + memset(&r, 0, sizeof(r)); r.h.ah = 3; r.h.bh = v->page; + int86(0x10, &r, &r); + v->cursor_start = r.h.ch; v->cursor_end = r.h.cl; + v->row = r.h.dh; v->col = r.h.dl; v->active = 1; + bios(mode, 0, 0, 0); + memset(&r, 0, sizeof(r)); r.h.ah = 15; + int86(0x10, &r, &r); + if (r.h.al != mode) { lte_dos_video_close(v); return 0; } + bios(0x0500, 0, 0, 0); /* page zero matches the borrowed B800 aperture */ + bios(0x0100, 0, 0x2000, 0); + v->view = view; + return 1; +} diff --git a/docs/ENGINE-VALIDATION.md b/docs/ENGINE-VALIDATION.md new file mode 100644 index 0000000..1f62bb4 --- /dev/null +++ b/docs/ENGINE-VALIDATION.md @@ -0,0 +1,116 @@ +# Engine validation — 2026-10-27 + +Candidate starts from public main `a034067` (merged compact-audio changes). +The engine is optional and no existing sound or graphics API was changed. +This record covers the C89 foreground engine, its explicit native presenters, +and the original graphics/text demonstration route; it is not a hardware +performance certification. + +## Checks + +- Managed UCRT64 GCC: all 16 host test executables passed, including the new + lifecycle/physics and presentation tests and the existing audio/graphics + suite. Strict C89, pedantic errors, warnings as errors, `-O2`. +- Open Watcom 1.9: normal complete DOS build passed with `-0 -ms -ecc -os -s + -w4 -wx`, 4096-byte demo stacks. New modules/tests produced no diagnostics. + The build still reports the preexisting always-false comparison in + `lt_time.c` and unreachable code in `gfx_host.c`. +- Focused native execution: fixed 86Box build, Tandy 1000 HX, 8088 at + 4,772,728 Hz, 640 KiB, copied DOS disk. ENGINE and ENGVIEW passed with + actual 16-bit types/far storage. ENGDOS passed mode selection and original + mode/page/cursor restoration for all four modes, invalid-mode rejection, + double-open/idempotent-close checks, and unchanged IRQ0/IRQ1 vectors. +- All five finite demonstration routes returned success to DOS. The BDA + reported the expected BIOS modes during execution; early/late headless + framebuffer captures were retained for each route. +- Independent reviewer replayed host engine/presentation binaries and reviewed + code and docs before local commit. Findings led to fixed-tick-only lifecycle + activation (including nested spawn chains/zero-step frames), correct far + fixture initialization, native warning guards, leading-edge collision + checks, and packed span/bank-address optimization. Final review found no + remaining blocker. + +| Run | Mode | Fixed ticks | Frames | Final debt | X/Y hits | Furthest camera X | Final body X,Y | +| --- | --- | --- | --- | --- | --- | --- | --- | +| ENGDEMO /auto | 09h, 320x200x16 | 300 | 76 | 0 | 1 / 141 | 316 | 330,176 | +| ENGDEMO /auto /low | 08h, 160x200x16 | 300 | 76 | 0 | 1 / 141 | 396 | 330,176 | +| TXTDEMO /auto | 03h, 80x25 | 300 | 76 | 0 | 1 / 141 | 128 | 330,176 | +| TXTDEMO /auto /40 | 01h, 40x25 | 300 | 76 | 0 | 1 / 141 | 312 | 330,176 | +| ENGDEMO /auto /slow | 09h, three-tick foreground wait | 300 | 76 | 0 | 1 / 141 | 314 | 330,176 | + +Awake/Start counts were 1/1 in every route; Update/LateUpdate counts matched. +The slow wait did not further reduce frame count on this emulator because +the viewport rendering already exceeded that wait. Determinism across +different frame partitions is directly tested in the portable/native core +test: 30 single-tick frames versus budgeted catch-up to the same 30 ticks; +also FixedUpdate -> Awake -> grandchild spawn chains with extra zero-step +frames versus one three-tick frame. Camera maxima may vary with LateUpdate +sampling; simulation results do not. + +An initial 240-second exploratory run timed out during the unoptimized +graphics route. A second exploratory run was deliberately stopped after the +renderer bottleneck was identified. Both owned emulator children stopped +and released the shared byte-range lease. The final optimized suite reached +DONE, stopped its child and released that same lease. No unrelated emulator, +disk image or panning source was modified. The raw headless graphics captures +can include stale pixels outside the active 200-row scene; those pixels are +not evidence about the display adapter. Bank/padding/nibble correctness is +also checked directly by the native far-buffer presentation test. + +## Native cost + +Measured from Open Watcom OMF SEGDEF records, not object-file length: + +| Optional module | 8088 CODE bytes | Module global data bytes | +| --- | --- | --- | +| `lt_engine.c` | 2089 | 0 | +| `lt_engine_view.c` | 622 | 0 | +| `lt_engine_dos.c` | 447 | 0 | +| Total | 3158 | 0 | + +These figures exclude shared C runtime helpers (integer arithmetic, +`memset`, far fill and BIOS wrappers) and linker alignment. Application cost +depends on which helpers it already uses. Omitting the modules adds no +engine code or state to existing applications. + +Actual native `sizeof`: world 18 bytes, slot 6, behavior table 10, body 8, +camera 8, tilemap descriptor 14, view 14, DOS video object 22. Eight slots and +one world therefore require 66 bytes, plus caller data/behavior tables. +The demo tile cells require 2400 bytes; no engine heap or interrupt stack. + +ENGDEMO.EXE is 15,912 bytes and TXTDEMO.EXE 15,910 bytes. MZ loader minimum +image/allocation plus PSP is respectively 22,976 and 22,960 bytes (rounding +the load image up to paragraphs before adding minimum allocation), including +the linked 4096-byte stack and demo/static/CRT data, excluding the environment +and DOS bookkeeping. Both EXEs retain the compiler's default maximum-memory +request; these header minima are not a tested low-memory startup threshold. +The display apertures are 32 KiB for mode 9, 16 KiB for mode 8, and 4000/2000 +bytes for text. The engine allocates no offscreen framebuffer. + +## Reproduction and evidence + +```powershell +python tools/build.py --target host --gcc-launcher C:/Users/chad/Documents/Codex/toolchains/gcc-ucrt64.py --runtime-bin C:/msys64/ucrt64/bin +python tools/build.py --target dos --watcom C:/WATCOM +``` + +Run ENGINE, ENGVIEW and ENGDOS in DOS on compatible Tandy hardware, then the +five commands above. Consumers do not need Python or converted assets. + +Developer evidence on the validation host: +`C:/Users/chad/Documents/Codex/2026-10-27/task/native-engine-03/RESULT.json` +and adjacent CORE/VIEW/VIDEO/G320/G160/T80/T40/SLOW logs and PNGs. +Host/DOS compiler invocation manifests are under `build/host/BUILD.json` and +`build/dos/BUILD.json`; OMF segment totals are `build/dos/ENGINE-COST.json`. +The machine-local developer harness and measurement script are outside the +repository and are not consumer dependencies. + +Final native binary SHA-256: + +```text +ENGINE.EXE e88b1480bcfa405499b20138e0fd59400a8714ea7a03e4b026e654a143efb703 +ENGVIEW.EXE 48e67d3b46992d6e0c7ec9216094a6dcd3bd1fef6922cb70f777b6d2d75cd622 +ENGDOS.EXE dcfbfdc2c9f0f03c3209202165647846af6606795017c1277eb5dfcefac8d25b +ENGDEMO.EXE 5f3f80eaa62734c6949d61c798a917c6ae790b2ff544d388320a108f103660de +TXTDEMO.EXE 2c5be6da695efe692bbcebf2bce43082572a8971fc8c27915aec57b45c763c37 +``` diff --git a/docs/ENGINE.md b/docs/ENGINE.md new file mode 100644 index 0000000..0ff35d2 --- /dev/null +++ b/docs/ENGINE.md @@ -0,0 +1,189 @@ +# Optional foreground game engine + +`lt_engine.h` adds small C89 game-loop conveniences without changing any +existing API. Compile `src/lt_engine.c` only when wanted. It owns no heap, +clock, interrupt, sound channel or display. Caller-owned object slots hold a +behavior table and user pointer; the user pointer can refer to any game data, +including a physics body. Simulation units need not be pixels. + +## Lifecycle and time + +```c +static lte_object slots[8]; +static lte_world game; +static const lte_behavior actor = { awake, start, fixed, update, late }; +/* Each callback: void callback(lte_world *w, lte_object *o). */ +lte_init(&game, slots, 8, 10, 4); +lte_add(&game, &actor, &my_actor_data); +/* Foreground, once per drawn frame; time units are chosen by the caller. */ +lte_frame(&game, elapsed_since_previous_frame); +``` + +The period here is 10 caller time units. Each frame adds elapsed time to +`game.debt`, then runs at most four complete fixed passes. For each pass: + +1. Reclaim removed slots and snapshot pending objects. +2. Call all pending **Awake**, in slot order. +3. Call all surviving pending **Start**, in slot order. +4. Call all active **FixedUpdate**, in slot order, then increment `game.ticks`. + +After the fixed passes, call all active **Update**, then all active +**LateUpdate**, once each in slot order. Awake/Start run exactly once per +surviving object, before its first FixedUpdate. Removing an object during +Awake suppresses its Start. Zero-step frames run Update/LateUpdate +on existing active objects but do not activate or reclaim objects. Keeping +lifecycle transitions at fixed-tick boundaries makes simulation spawn chains +independent of rendering cadence, including extra zero-elapsed frames. + +Creation inside any callback becomes eligible at the next fixed-tick boundary, +never halfway through the current pass. This includes creation inside +Awake/Start. Removal immediately suppresses all later callbacks; the dead +slot cannot be reused until the next fixed-tick boundary. `lte_add` returns +NULL when storage is full. Never retain an object pointer after removal: +slots are reusable and pointers are not generation handles. Behavior tables, +user data and the slot array must outlive their objects. Treat world/object +internals as read-only except your own `user` data. Do not initialize a world +again inside a callback. Reentrant `lte_frame` returns -1. + +Fixed callbacks run in the foreground, never from the engine's own ISR. +Slow drawing retains debt and catches up within the budget on later frames; +it never silently changes the fixed period. A 32-bit time accumulator +saturates on overflow and sets sticky `game.saturated`, making lost time +observable. Ticks wrap modulo 2^32. For a pause, stop advancing caller time +or deliberately rebase the caller's clock. The engine does not infer pauses. +Deterministic simulation belongs in FixedUpdate with tick-indexed inputs; +Update/LateUpdate run per rendered frame and are not a deterministic clock. + +The callback budget limits the number of passes, each visiting at most the +slot capacity. It cannot bound arbitrary user callback code. A long stall can +leave a visible backlog: choose a period/budget appropriate to the 8088 and +monitor debt. There are no threads, global state or unbounded catch-up loops. +Do not call the engine from an interrupt or concurrently. The existing +`lttk_work` API is unchanged; do not install game callbacks there to drive +this engine. A foreground caller may sample `lttk_ticks()` if it separately +owns that optional clock, or simply use the unchanged BIOS clock as the demos do. + +## Collision and camera + +`lte_box` is an integer half-open AABB. `lte_overlap` tests object/object +overlap: a shared edge is not a collision. It does not automatically resolve +object pairs or provide rigid-body dynamics, mass, rotation, impulses or a +broad phase. A game chooses its pair list and response. + +`lte_move_tiles` resolves X then Y, one world unit at a time, against tile +cells whose masked bits are nonzero. It validates the original body, then +tests each new leading edge, preventing tunneling through a one-cell wall. +Outside the map is solid. It returns `LTE_HIT_X`/`LTE_HIT_Y`; the caller can +stop or reverse velocity, apply gravity, or jump on a downward Y hit. Bodies +already inside solids are rejected unchanged rather than guessed out of them. +Invalid map/body/displacement returns `LTE_MOVE_INVALID` without mutation. + +Coordinates are -16384..16383, body sizes 1..1024, displacement -256..256 per +axis per call. Maps have at most 65535 cells and at most 16384 world units +per axis. Cells and video buffers may be far pointers on Watcom, but each +array must remain within one 64 KiB segment; no huge-pointer traversal. +Object storage and behavior/user near pointers must fit the chosen memory +model/DGROUP. No per-object body or sprite is imposed. + +Collision work is bounded by the initial overlapped tile rectangle plus +`abs(dx)` leading columns and `abs(dy)` leading rows. A conservative probe +bound is `C*R + abs(dx)*R + abs(dy)*C`, where +`C=ceil((body.w-1)/tile_w)+1`, `R=ceil((body.h-1)/tile_h)+1`. +Typical demo bodies/tiles are 8x8 with displacement 2 horizontally and at +most 6 vertically. Huge bodies on 1-unit tiles remain expensive on an 8088. + +`lte_camera_set` clamps a viewport to world bounds, pinning oversized +viewports to zero. `lte_visible_tiles` visits only the visible map cells, +row-major, with camera-relative rectangles. Partially visible edge tiles may +have negative coordinates; the presenter clips them. The map is bounded, +not wrapping. Drawing the visible viewport each frame is software tilemap +scrolling; there is no new hardware panning/page-flip or smooth-frame-rate claim. + +## Actual display coverage + +`lt_engine_view.h` / `src/lt_engine_view.c` are separate optional presenters. +They bind caller-owned memory and clip colored rectangles; they never set a +mode themselves. Glyphs/attributes are supplied for text rectangles. + +| BIOS mode | Native display | Memory layout | Bytes to bind | +| --- | --- | --- | --- | +| 09h | Tandy 320x200, 16 colors | four 8 KiB banks, 160 bytes/row | 32768 | +| 08h | Tandy 160x200, 16 colors | two 8 KiB banks, 80 bytes/row | 16384 | +| 03h | color text 80x25 | character/attribute pairs, 160 bytes/row | 4000 | +| 01h | color text 40x25 | character/attribute pairs, 80 bytes/row | 2000 | + +In graphics, even pixels use the high nibble; row bank is `y % banks`, +row within bank is `y / banks`. Padding is untouched. Text presentation +uses character cells, so the demo scales the same 8-unit world tile to one +cell and aligns its camera to 8 units. Physics and gravity remain identical. +Text's coarser display naturally hides fractional-cell movement. + +`lt_engine_dos.h` / `adapters/lt_engine_dos.c` optionally save the BIOS +mode/page/cursor, enter one of these modes at page zero, bind B800:0000 and +restore the saved state on close or failed mode verification. Initialize +with `LTE_DOS_VIDEO_INITIALIZER`, keep the object at its original address, +and pair each successful open with close. Repeated close is safe. The caller +asserts compatible Tandy hardware for 08h/09h; the BIOS mode query verifies +selection, not adapter identity. This resets the BIOS palette; it does not +save prior pixels, custom palette or arbitrary registers. Exclusive video +ownership is required. No audio or timer resources are acquired. + +The preexisting `lt_gfx` raster remains specific to 320x200 mode 9. The new +160x200 support is an explicit two-bank packed presenter, not a claim that +INDEX8 RAM surfaces select a native mode. CGA 04h/06h, 160x100 text tricks, +Tandy 0Ah, monochrome text and other resolutions have no new adapter here. +The simulation and camera can still be reused with a caller's renderer. + +## Ready-to-run DOS demonstrations + +[Download the qualified ENGINE.ZIP](https://github.com/astrobleem/libtandy/raw/refs/heads/main/examples/engine/ENGINE.ZIP) +for self-contained DOS executables and instructions, or build from source. +The [package manifest](../examples/engine/DOS-BUILD.json) and CI bind the +download to its qualified source and native execution receipt. + +Build with the normal Open Watcom helper: + +```powershell +python tools/build.py --target dos --watcom C:/WATCOM +``` + +Python is only a developer build helper. A consumer runs the EXEs in DOS +on compatible hardware without Python, an asset converter or PC preparation. +The sources can also be compiled directly with matching 8088 small-model +flags; see the source list in `tools/build.py`. + +```text +wcl -q -bt=dos -0 -ms -ecc -os -s -i=include -k4096 -fe=ENGDEMO.EXE examples/engine_demo.c src/lt_engine.c src/lt_engine_view.c adapters/lt_engine_dos.c src/lt_time.c adapters/lt_time_dos.c +``` + +```text +ENGDEMO Tandy mode 9, scrolling/collision/gravity +ENGDEMO /low Tandy native mode 8, 160x200 +TXTDEMO 80x25 text, same simulation +TXTDEMO /40 40x25 text, same simulation +``` + +A/D or arrows set horizontal velocity; Space jumps while grounded; Escape +restores video and quits. This small example uses BIOS press events, not +held keys. No sound is played. The map contains repeating colored landmarks, +a floor and a tall blocking wall. The camera follows the moving body through +the 768-unit world. There is no sprite asset or loading step. + +Add `/auto` for an automatic 300-tick route that jumps, hits the wall, +reverses, and prints its collision counts, furthest camera position and +lifecycle counts after returning to DOS. Add `/slow` to wait three BIOS +ticks between foreground frames. Physics still uses one BIOS tick per fixed +step (about 18.2 Hz), with up to four steps per frame. The demo caps its +finite route at exactly 300 ticks even if the caller samples a larger stall. +Direct viewport redraw can tear and will be slow on an 8088; this is a small +correctness/convenience example, not a high-frame-rate platformer benchmark. + +Host tests cover ordering, callback removal/addition, nested-frame rejection, +slow-frame debt, deterministic spawn chains with zero-step frames, half-open +collision edges, tunneling, invalid movement, camera bounds and partial +tiles. Presentation tests check native bank addresses, nibble preservation, +clipping and text attributes in both column counts. DOS builds compile those +same tests with native 16-bit types and far pointers. + +See [the validation and cost record](ENGINE-VALIDATION.md) for the measured +8088 sizes, native mode runs and their practical limits. diff --git a/examples/engine/DOS-BUILD.json b/examples/engine/DOS-BUILD.json new file mode 100644 index 0000000..d19aee5 --- /dev/null +++ b/examples/engine/DOS-BUILD.json @@ -0,0 +1,82 @@ +{ + "qualified_source_commit": "d97d173", + "compiler": "Open Watcom 1.9", + "flags": [ + "-bt=dos", + "-0", + "-ms", + "-ecc", + "-os", + "-s", + "-w4", + "-wx", + "-k4096" + ], + "source_lf_sha256": { + "src/lt_engine.c": "d9bd67954865ed85513652d4c97d1b999d39b8ab59076cf217c037447c817586", + "src/lt_engine_view.c": "84df1b9e00adab45afaa4893e78a22d315bb91d63f2e0574231e40a326d42ba7", + "adapters/lt_engine_dos.c": "7e6900fd0d5df7211b124eac75151472afee444ad8448cde51fd8c141d130729", + "examples/engine_demo.c": "8439e7765708282d439a2199cee5422b89196e78d2b41d3186421f1ab58c457b", + "examples/engine_text_demo.c": "b2689df94cc66238aa3a256b8209fff533c9e0de5ef0a5547c03437a86d41a7a", + "include/lt_engine.h": "1043bb78702f6a9488275fb1afd424984405450bdf24f5280feac2e7b71baeed", + "include/lt_engine_view.h": "cb9824e2140a462e23ecbeabe1d4af7bbb1b4528bb0b4cf89502519a81feb5c2", + "include/lt_engine_dos.h": "83bd6172bf2d28faf1d6fbd02c8928cd716f6dd92a709261a7a96ddf8b363994", + "tests/engine_core.c": "0365570e0278334058dce81319dc0b87ebe2f751bebb218c8d613160145de1a1", + "tests/engine_view.c": "347279286915072ae1329e3ae0abd5629ae45d338efd1f96138c57f62ebac2f5", + "tests/engine_dos.c": "53a50b7d26cc1dfcd3de5e7bc0eac61db3ddec79958ef5b249e2229ff0a0be7e", + "src/lt_time.c": "d26bea522d482b0134cdbc6fb3b642a5e34f32ee5a3b8fdebb8e29f152ab841f", + "adapters/lt_time_dos.c": "beb10baca039286fe550b428c984b17201c30ab506459ef1c153e7c6adc99817", + "include/lt_time.h": "70a750125d08b6096896ad7a98a43cd4e340d17cebfae07b615b7b949ee39d96", + "include/lt_time_dos.h": "7102506c96865900947cfbc6fdcf47b8b5c0af4b8b1c4eae8d234f8aa69a252a", + "include/lt_surface.h": "5ce7a074d1bc734fd460ff70590e4a8234f84df3f95e7beceed226854b4392a9" + }, + "zip_sha256": "1ab2c7f0441464cca06f3d28cf07947ca075900bda6fa439da71e1015aa6451d", + "members": { + "DOCS/ENGINE.MD": { + "bytes": 10486, + "sha256": "bd2ce10965513e3bc76f9c3e5f80f27729c045ab64fe74094c5afb49d78c88e2" + }, + "DOCS/VALIDATE.MD": { + "bytes": 6358, + "sha256": "de158a2bbdd628bd4b3bff17470703c6601a6a4cff83cb5b8fa711a07e5b1324" + }, + "ENGDEMO.EXE": { + "bytes": 15912, + "sha256": "5f3f80eaa62734c6949d61c798a917c6ae790b2ff544d388320a108f103660de" + }, + "ENGDOS.EXE": { + "bytes": 11498, + "sha256": "dcfbfdc2c9f0f03c3209202165647846af6606795017c1277eb5dfcefac8d25b" + }, + "ENGINE.EXE": { + "bytes": 13578, + "sha256": "e88b1480bcfa405499b20138e0fd59400a8714ea7a03e4b026e654a143efb703" + }, + "ENGVIEW.EXE": { + "bytes": 42966, + "sha256": "48e67d3b46992d6e0c7ec9216094a6dcd3bd1fef6922cb70f777b6d2d75cd622" + }, + "LICENSE.TXT": { + "bytes": 1063, + "sha256": "1b7073ca79c0f526b7017032b475e99b0443ad0bac029169a2d646a12e533985" + }, + "README.TXT": { + "bytes": 746, + "sha256": "a73df1ae350591b5c26381934848e0efa0c83e8f785f75299581050345dd3a7e" + }, + "RUNCHECK.BAT": { + "bytes": 206, + "sha256": "40cf27bf947892d1e4bfea72d8a397cefa357fcf05caebf45c4ff8ddcdab3205" + }, + "TXTDEMO.EXE": { + "bytes": 15910, + "sha256": "2c5be6da695efe692bbcebf2bce43082572a8971fc8c27915aec57b45c763c37" + } + }, + "document_members": { + "DOCS/ENGINE.MD": "docs/ENGINE.md", + "DOCS/VALIDATE.MD": "docs/ENGINE-VALIDATION.md", + "LICENSE.TXT": "LICENSE" + }, + "native_lf_sha256": "59f43dc5b506abda53b58bb4eaeb50fee860b8ae85c37e3e014bba0c09c3f9bd" +} diff --git a/examples/engine/ENGINE.ZIP b/examples/engine/ENGINE.ZIP new file mode 100644 index 0000000..95ab0d9 Binary files /dev/null and b/examples/engine/ENGINE.ZIP differ diff --git a/examples/engine/NATIVE.json b/examples/engine/NATIVE.json new file mode 100644 index 0000000..bbf942e --- /dev/null +++ b/examples/engine/NATIVE.json @@ -0,0 +1,89 @@ +{ + "complete": true, + "cpu_hz": 4772728, + "started": "2026-10-27T14:32:26.495909+00:00", + "captures": [ + { + "label": "G320-early", + "mode": 9, + "bytes": 1350 + }, + { + "label": "G320-late", + "mode": 9, + "bytes": 1432 + }, + { + "label": "G160-early", + "mode": 8, + "bytes": 1486 + }, + { + "label": "G160-late", + "mode": 8, + "bytes": 1436 + }, + { + "label": "T80-early", + "mode": 3, + "bytes": 1764 + }, + { + "label": "T80-late", + "mode": 3, + "bytes": 1747 + }, + { + "label": "T40-early", + "mode": 1, + "bytes": 1574 + }, + { + "label": "T40-late", + "mode": 1, + "bytes": 1509 + }, + { + "label": "SLOW-early", + "mode": 9, + "bytes": 4442 + }, + { + "label": "SLOW-late", + "mode": 9, + "bytes": 4515 + } + ], + "TXTDEMO.EXE": { + "bytes": 15910, + "sha256": "2c5be6da695efe692bbcebf2bce43082572a8971fc8c27915aec57b45c763c37" + }, + "ENGINE.EXE": { + "bytes": 13578, + "sha256": "e88b1480bcfa405499b20138e0fd59400a8714ea7a03e4b026e654a143efb703" + }, + "ENGVIEW.EXE": { + "bytes": 42966, + "sha256": "48e67d3b46992d6e0c7ec9216094a6dcd3bd1fef6922cb70f777b6d2d75cd622" + }, + "ENGDEMO.EXE": { + "bytes": 15912, + "sha256": "5f3f80eaa62734c6949d61c798a917c6ae790b2ff544d388320a108f103660de" + }, + "ENGDOS.EXE": { + "bytes": 11498, + "sha256": "dcfbfdc2c9f0f03c3209202165647846af6606795017c1277eb5dfcefac8d25b" + }, + "lease_acquired": true, + "owned_pid": 22140, + "owned_child_stopped": true, + "lease_released": true, + "CORE": "engine passed lifecycle, mutation, foreground debt, collision, camera; world=18 object=6 box=8 camera=8 map=14 behavior=10 bytes\r\n", + "VIEW": "engine view passed native 8/9 banks, nibble edges, text 1/3, clipping; view=14 bytes\r\n", + "VIDEO": "engine DOS mode/page/cursor restore, double-open/close, invalid mode, IRQ0/1 unchanged: PASS; video=22 bytes\r\n", + "G320": "ENGINE mode=9 ticks=300 frames=76 debt=0 awake/start=1/1 hits=1/141 camera=316 xy=330,176 PASS\r\n", + "G160": "ENGINE mode=8 ticks=300 frames=76 debt=0 awake/start=1/1 hits=1/141 camera=396 xy=330,176 PASS\r\n", + "T80": "ENGINE mode=3 ticks=300 frames=76 debt=0 awake/start=1/1 hits=1/141 camera=128 xy=330,176 PASS\r\n", + "T40": "ENGINE mode=1 ticks=300 frames=76 debt=0 awake/start=1/1 hits=1/141 camera=312 xy=330,176 PASS\r\n", + "SLOW": "ENGINE mode=9 ticks=300 frames=76 debt=0 awake/start=1/1 hits=1/141 camera=314 xy=330,176 PASS\r\n" +} diff --git a/examples/engine/README.md b/examples/engine/README.md new file mode 100644 index 0000000..e40f09d --- /dev/null +++ b/examples/engine/README.md @@ -0,0 +1,33 @@ +# Optional engine DOS demonstrations + +[Download ENGINE.ZIP](https://github.com/astrobleem/libtandy/raw/refs/heads/main/examples/engine/ENGINE.ZIP) +for the qualified, self-contained DOS executables. Extract to a directory on +compatible Tandy DOS hardware and run explicitly. No Python, assets, setup +program or conversion step is needed on the DOS machine. + +| Command | Display | +| --- | --- | +| `ENGDEMO` | Tandy 320x200x16 (mode 09h) | +| `ENGDEMO /low` | Tandy 160x200x16 (mode 08h) | +| `TXTDEMO` | Color text 80x25 (mode 03h) | +| `TXTDEMO /40` | Color text 40x25 (mode 01h) | + +A/D or arrows set horizontal velocity; Space jumps; Escape quits and restores +video. Each example uses the same world, tile collisions and gravity, with a +camera following the moving body through a scrolling map. Add `/auto` for a +finite 300-fixed-tick route, or `/auto /slow` for a three-BIOS-tick foreground +wait. The engine runs fixed callbacks in the foreground; it owns no timer, +ISR, sound channel or heap. These are small convenience examples, not a +high-frame-rate platformer benchmark. + +`RUNCHECK.BAT` runs ENGINE/ENGVIEW/ENGDOS, the native lifecycle/physics, +presentation and mode-restoration checks. The ZIP also contains the guide, +validation record and MIT license. It bundles no emulator, ROM, DOS or compiler. + +The [engine guide](../../docs/ENGINE.md) documents optional source integration, +supported display layouts and bounds. The [validation record](../../docs/ENGINE-VALIDATION.md) +records the 4.77 MHz Tandy HX emulator results and code/state costs. +[DOS-BUILD.json](DOS-BUILD.json) pins the qualified source and ZIP members; +[NATIVE.json](NATIVE.json) records exact tested binary hashes and runtime results. +CI checks the committed download without requiring an emulator or compiler +for its verification. diff --git a/examples/engine_demo.c b/examples/engine_demo.c new file mode 100644 index 0000000..c505808 --- /dev/null +++ b/examples/engine_demo.c @@ -0,0 +1,132 @@ +/* SPDX-License-Identifier: MIT */ +/* ENGDEMO [/low] [/auto] [/slow], TXTDEMO [/40] [/auto] [/slow]. + * Same world and physics in both executables; BIOS clock/keyboard only. + * Arrows/A/D set horizontal velocity, Space jumps, Escape quits. + * Automatic route stops after 300 fixed ticks and prints evidence in DOS. */ +#include +#include +#include +#include "lt_engine_dos.h" +#include "lt_time_dos.h" +#define MAP_W 96 +#define MAP_H 25 +static unsigned char cells[MAP_W * MAP_H]; +static lte_tilemap map = {cells, MAP_W, MAP_H, 8, 8, 128}; +static lte_dos_video video = LTE_DOS_VIDEO_INITIALIZER; +static lte_world world; +static lte_object objects[2]; +static lte_box player = {16,160,8,8}; +static lte_camera camera; +static short vx = 2, vy; +static unsigned scale = 1, automatic, grounded, hit_x, hit_y, max_camera; +static unsigned awake_count, start_count, updates, late_count; +static void awake(lte_world *w, lte_object *o) +{ (void)w; (void)o; ++awake_count; } +static void start(lte_world *w, lte_object *o) +{ (void)w; (void)o; ++start_count; } +static void fixed(lte_world *w, lte_object *o) +{ + int hit; + (void)o; + if (automatic && grounded && w->ticks % 20UL == 0) vy = -6; + if (vy < 4) ++vy; + hit = lte_move_tiles(&map, &player, vx, vy); + grounded = (hit & LTE_HIT_Y) && vy > 0; + if (hit & LTE_HIT_X) { ++hit_x; if (automatic) vx = (short)-vx; } + if (hit & LTE_HIT_Y) { ++hit_y; vy = 0; } +} +static void update(lte_world *w, lte_object *o) +{ (void)w; (void)o; ++updates; } +static void late(lte_world *w, lte_object *o) +{ + short x, width = (short)(video.view.width * scale); + (void)w; (void)o; ++late_count; + x = (short)(player.x + player.w / 2 - width / 2); + /* Text camera is cell-aligned. Simulation coordinates remain unchanged. */ + if (x > 0) x = (short)(x / (short)scale * (short)scale); + lte_camera_set(&camera, x, 0, width, 200, MAP_W * 8, MAP_H * 8); + if ((unsigned)camera.x > max_camera) max_camera = (unsigned)camera.x; +} +static const lte_behavior behavior = {awake, start, fixed, update, late}; +static void tile(void *user, unsigned char value, short x, short y, + short w, short h) +{ + lte_view *v = (lte_view *)user; + unsigned char ink = value & 15; + if (value == 1) return; /* common sky is one large span fill per frame */ + if (scale == 1) lte_view_rect(v, x, y, w, h, ink, 0); + else lte_view_rect(v, (short)(x / 8), (short)(y / 8), + (short)(w / 8), (short)(h / 8), + (unsigned char)(ink << 4), ' '); +} +int main(int argc, char **argv) +{ + unsigned mode, slow = 0, x, y; + int i, quit = 0, key, ok; + unsigned long previous, now, elapsed; +#ifdef ENGINE_TEXT + mode = LTE_VIEW_TEXT80; scale = 8; +#else + mode = LTE_VIEW_TANDY320; +#endif + for (i = 1; i < argc; ++i) { + if (!strcmp(argv[i], "/auto")) automatic = 1; + else if (!strcmp(argv[i], "/slow")) slow = 1; +#ifdef ENGINE_TEXT + else if (!strcmp(argv[i], "/40")) mode = LTE_VIEW_TEXT40; +#else + else if (!strcmp(argv[i], "/low")) mode = LTE_VIEW_TANDY160; +#endif + else { puts("Use /auto /slow and /low (graphics) or /40 (text)"); return 1; } + } + for (y = 0; y < MAP_H; ++y) + for (x = 0; x < MAP_W; ++x) + cells[y * MAP_W + x] = (unsigned char)( + y >= 23 ? 128U | (2U + (x % 3U)) : + (x == 60 && y >= 16 ? 128U | 6U : 1U)); + /* Decorative stripes expose camera movement without external assets. */ + for (x = 0; x < MAP_W; x += 8) cells[10 * MAP_W + x] = 3; + if (!lte_init(&world, objects, 2, 1, 4) || + !lte_add(&world, &behavior, 0)) return 1; + if (!lte_dos_video_open(&video, mode)) { + puts("Requested video mode unavailable (8/9 require Tandy hardware)"); + return 1; + } + previous = ltd_raw_ticks(); + while (!quit && (!automatic || world.ticks < 300UL)) { + now = ltd_raw_ticks(); + elapsed = now >= previous ? now - previous : LTT_DAY_TICKS - previous + now; + if (elapsed < (slow ? 3UL : 1UL)) continue; + previous = now; + if (automatic) { + unsigned long remaining = 300UL - world.ticks; + if (world.debt >= remaining) elapsed = 0; + else if (elapsed > remaining - world.debt) elapsed = remaining - world.debt; + } + while (kbhit()) { + key = getch(); + if (!key) key = 256 + getch(); + if (key == 27) quit = 1; + if (key == 'a' || key == 256 + 75) vx = -2; + if (key == 'd' || key == 256 + 77) vx = 2; + if (key == ' ' && grounded) vy = -6; + } + lte_frame(&world, elapsed); + lte_view_rect(&video.view, 0, 0, (short)video.view.width, + (short)video.view.height, (unsigned char)(scale == 1 ? 1 : 0x10), ' '); + lte_visible_tiles(&map, &camera, tile, &video.view); + lte_view_rect(&video.view, + (short)((player.x - camera.x) / (short)scale), + (short)(player.y / (short)scale), + (short)(player.w / (short)scale), (short)(player.h / (short)scale), + (unsigned char)(scale == 1 ? 15 : 0x1f), '@'); + } + lte_dos_video_close(&video); + ok = !automatic || (awake_count == 1 && start_count == 1 && + updates == late_count && hit_x && hit_y && max_camera && !world.saturated); + printf("ENGINE mode=%u ticks=%lu frames=%u debt=%lu awake/start=%u/%u " + "hits=%u/%u camera=%u xy=%d,%d %s\n", mode, world.ticks, updates, + world.debt, awake_count, start_count, hit_x, hit_y, max_camera, + player.x, player.y, ok ? "PASS" : "FAIL"); + return ok ? 0 : 1; +} diff --git a/examples/engine_text_demo.c b/examples/engine_text_demo.c new file mode 100644 index 0000000..62d5f1f --- /dev/null +++ b/examples/engine_text_demo.c @@ -0,0 +1,3 @@ +/* SPDX-License-Identifier: MIT */ +#define ENGINE_TEXT +#include "engine_demo.c" diff --git a/include/lt_engine.h b/include/lt_engine.h new file mode 100644 index 0000000..753d323 --- /dev/null +++ b/include/lt_engine.h @@ -0,0 +1,81 @@ +/* SPDX-License-Identifier: MIT */ +#ifndef LT_ENGINE_H +#define LT_ENGINE_H +#include +#if UCHAR_MAX != 255 || USHRT_MAX != 65535U || LONG_MAX < 2147483647L +#error lt_engine requires 8-bit bytes, 16-bit shorts and 32-bit longs +#endif +#if defined(__WATCOMC__) +#define LTE_FAR __far +#else +#define LTE_FAR +#endif + +typedef struct lte_world lte_world; +typedef struct lte_object lte_object; +typedef void (*lte_callback)(lte_world *, lte_object *); +typedef struct { + lte_callback awake, start, fixed_update, update, late_update; +} lte_behavior; +struct lte_object { + const lte_behavior *behavior; + void *user; + unsigned char state; /* private: use add/remove, never change directly */ +}; +struct lte_world { + lte_object *objects; + unsigned capacity, period, budget; + unsigned long debt, ticks; + unsigned char busy, saturated; +}; +/* Storage remains at this address; near data must fit the caller's memory + * model. Init erases storage. period is in caller-defined time units, 1..65535; + * budget is 1..255 fixed passes per frame. No allocation or timer/ISR. */ +int lte_init(lte_world *, lte_object *, unsigned capacity, + unsigned period, unsigned budget); +/* Pending objects activate at the NEXT fixed-tick boundary, including during + * catch-up. Zero-step frames never activate objects. Slots are stable until + * removal; do not retain a pointer after removal. Dead slots are reclaimed + * at the next fixed-tick boundary. Behavior/user must outlive the object. */ +lte_object *lte_add(lte_world *, const lte_behavior *, void *user); +int lte_remove(lte_world *, lte_object *); +/* Boundary order: reclaim, all Awake, all Start. Frame order: 0..budget + * complete [boundary, FixedUpdate] passes, all Update, all LateUpdate. + * Awake/Start run once, before the object's first FixedUpdate; no Update or + * LateUpdate for pending objects. Removal suppresses remaining callbacks + * immediately. Recursion is rejected. Debt is retained under slow rendering; + * 32-bit saturation is sticky/observable, never an unbounded catch-up loop. + * elapsed <= 0xffffffff; returns steps run, or -1 for invalid/reentrant call. */ +int lte_frame(lte_world *, unsigned long elapsed); + +/* World units are integers, independent of pixels/cells. Valid boxes have + * x/y in -16384..16383 and w/h in 1..1024. Half-open: touching is not overlap. */ +typedef struct { short x, y, w, h; } lte_box; +typedef struct { + const unsigned char LTE_FAR *cells; + unsigned width, height, tile_w, tile_h; + unsigned char solid_mask; /* nonzero masked bits mean solid */ +} lte_tilemap; +typedef struct { short x, y, w, h; } lte_camera; +#define LTE_HIT_X 1 +#define LTE_HIT_Y 2 +#define LTE_MOVE_INVALID 4 +int lte_overlap(const lte_box *, const lte_box *); +/* Maps: nonempty, <=65535 cells, each axis <=16384 world units. Outside is + * solid. Movement resolves X then Y in unit steps (no tunneling), <=256 units + * per axis per call. Caller integrates velocity/gravity in FixedUpdate. + * Invalid input leaves box unchanged, returns LTE_MOVE_INVALID. */ +int lte_map_valid(const lte_tilemap *); +int lte_blocked(const lte_tilemap *, const lte_box *); +int lte_move_tiles(const lte_tilemap *, lte_box *, short dx, short dy); +/* Clamp requested top-left to world; a viewport larger than world pins to 0. + * All sizes positive <=16384, world sizes <=16384. Invalid: unchanged/0. */ +int lte_camera_set(lte_camera *, short x, short y, short w, short h, + short world_w, short world_h); +typedef void (*lte_tile_visit)(void *, unsigned char, short, short, + short, short); +/* Visits visible cells only, row-major, with camera-relative rectangle. + * Partial tiles may extend past viewport: renderer clips. No allocation. */ +int lte_visible_tiles(const lte_tilemap *, const lte_camera *, + lte_tile_visit, void *); +#endif diff --git a/include/lt_engine_dos.h b/include/lt_engine_dos.h new file mode 100644 index 0000000..b30e4b2 --- /dev/null +++ b/include/lt_engine_dos.h @@ -0,0 +1,16 @@ +/* SPDX-License-Identifier: MIT */ +#ifndef LT_ENGINE_DOS_H +#define LT_ENGINE_DOS_H +#include "lt_engine_view.h" +typedef struct { + lte_view view; + unsigned char active, mode, page, cursor_start, cursor_end, row, col; +} lte_dos_video; +#define LTE_DOS_VIDEO_INITIALIZER { {0,0,0,0,0,0}, 0,0,0,0,0,0,0 } +/* Foreground Open Watcom DOS only. Caller asserts compatible Tandy hardware + * for modes 8/9 (BIOS mode query is not hardware detection). Saves/restores + * mode/page/cursor, not old screen contents or custom palettes/registers. + * No sound or timer ownership. Exclusive video owner; object must not move. */ +int lte_dos_video_open(lte_dos_video *, unsigned mode); +void lte_dos_video_close(lte_dos_video *); +#endif diff --git a/include/lt_engine_view.h b/include/lt_engine_view.h new file mode 100644 index 0000000..48ed85d --- /dev/null +++ b/include/lt_engine_view.h @@ -0,0 +1,25 @@ +/* SPDX-License-Identifier: MIT */ +#ifndef LT_ENGINE_VIEW_H +#define LT_ENGINE_VIEW_H +#include "lt_engine.h" +/* Native B800 layouts only. Binding does not set a BIOS mode. The caller + * supplies the matching aperture or a RAM buffer of at least bytes length. + * Text units are character cells. Mode 8/9 units are pixels. No mode claims + * for INDEX8 RAM surfaces, VGA, CGA 4/6, or Tandy mode 0Ah. */ +#define LTE_VIEW_TEXT40 1 +#define LTE_VIEW_TEXT80 3 +#define LTE_VIEW_TANDY160 8 +#define LTE_VIEW_TANDY320 9 +typedef struct { + unsigned char LTE_FAR *pixels; + unsigned width, height, stride, banks; + unsigned char mode; +} lte_view; +int lte_view_bind(lte_view *, unsigned mode, + unsigned char LTE_FAR *pixels, unsigned bytes); +/* Half-open, clipped rectangle. In graphics ink low nibble is color; + * in text ink is the complete attribute and glyph the character byte. + * No mode setting, port I/O, page flipping, palette ownership or heap. */ +void lte_view_rect(lte_view *, short x, short y, short w, short h, + unsigned char ink, unsigned char glyph); +#endif diff --git a/src/lt_engine.c b/src/lt_engine.c new file mode 100644 index 0000000..8199e93 --- /dev/null +++ b/src/lt_engine.c @@ -0,0 +1,204 @@ +/* SPDX-License-Identifier: MIT */ +#include "lt_engine.h" +#include +#define FREE 0 +#define PENDING 1 +#define READY 2 +#define ACTIVE 3 +#define DEAD 4 +#define MAX_TIME 0xffffffffUL + +int lte_init(lte_world *w, lte_object *objects, unsigned n, + unsigned period, unsigned budget) +{ + if (!w || !objects || !n || n > 65535U / sizeof(*objects) || + !period || !budget || budget > 255U) return 0; +#if UINT_MAX > 65535U + if (period > 65535U) return 0; +#endif + memset(w, 0, sizeof(*w)); + memset(objects, 0, n * sizeof(*objects)); + w->objects = objects; w->capacity = n; + w->period = period; w->budget = budget; + return 1; +} +lte_object *lte_add(lte_world *w, const lte_behavior *b, void *user) +{ + unsigned i; + if (!w || !w->objects || !b) return 0; + for (i = 0; i < w->capacity; ++i) { + lte_object *o = &w->objects[i]; + if (o->state == FREE) { + o->behavior = b; o->user = user; o->state = PENDING; + return o; + } + } + return 0; +} +int lte_remove(lte_world *w, lte_object *o) +{ + unsigned i; + if (!w || !w->objects || !o) return 0; + for (i = 0; i < w->capacity; ++i) { + if (&w->objects[i] == o && o->state != FREE && o->state != DEAD) { + o->state = DEAD; return 1; + } + } + return 0; +} +static void callbacks(lte_world *w, unsigned phase) +{ + unsigned i; + for (i = 0; i < w->capacity; ++i) { + lte_object *o = &w->objects[i]; + lte_callback cb = 0; + const lte_behavior *b = o->behavior; + if ((phase < 2 && o->state != READY) || + (phase >= 2 && o->state != ACTIVE)) continue; + if (phase == 0) cb = b->awake; + else if (phase == 1) { o->state = ACTIVE; cb = b->start; } + else if (phase == 2) cb = b->fixed_update; + else if (phase == 3) cb = b->update; + else cb = b->late_update; + if (cb) cb(w, o); + } +} +static void boundary(lte_world *w) +{ + unsigned i; + for (i = 0; i < w->capacity; ++i) { + if (w->objects[i].state == DEAD) { + memset(&w->objects[i], 0, sizeof(w->objects[i])); + } else if (w->objects[i].state == PENDING) + w->objects[i].state = READY; + } + callbacks(w, 0); callbacks(w, 1); +} +int lte_frame(lte_world *w, unsigned long elapsed) +{ + unsigned steps = 0; + if (!w || !w->objects || !w->period || !w->budget || w->busy) return -1; +#if ULONG_MAX > 0xffffffffUL + if (elapsed > MAX_TIME) return -1; +#endif + w->busy = 1; + if (elapsed > MAX_TIME - w->debt) { + w->debt = MAX_TIME; w->saturated = 1; + } else w->debt += elapsed; + while (w->debt >= w->period && steps < w->budget) { + boundary(w); + w->debt -= w->period; + callbacks(w, 2); + w->ticks = (w->ticks + 1UL) & MAX_TIME; + ++steps; + } + callbacks(w, 3); callbacks(w, 4); + w->busy = 0; + return (int)steps; +} +static int box_valid(const lte_box *b) +{ + return b && b->x >= -16384 && b->x <= 16383 && + b->y >= -16384 && b->y <= 16383 && + b->w > 0 && b->w <= 1024 && b->h > 0 && b->h <= 1024; +} +int lte_overlap(const lte_box *a, const lte_box *b) +{ + return box_valid(a) && box_valid(b) && + (long)a->x < (long)b->x + b->w && + (long)b->x < (long)a->x + a->w && + (long)a->y < (long)b->y + b->h && + (long)b->y < (long)a->y + a->h; +} +int lte_map_valid(const lte_tilemap *m) +{ + return m && m->cells && m->width && m->height && + m->tile_w && m->tile_h && m->width <= 16384U && + m->height <= 16384U && m->tile_w <= 16384U && + m->tile_h <= 16384U && + (unsigned long)m->width * m->height <= 65535UL && + (unsigned long)m->width * m->tile_w <= 16384UL && + (unsigned long)m->height * m->tile_h <= 16384UL; +} +static int blocked(const lte_tilemap *m, const lte_box *b) +{ + unsigned x, y, x0, y0, x1, y1; + if (b->x < 0 || b->y < 0 || + (long)b->x + b->w > (long)m->width * (long)m->tile_w || + (long)b->y + b->h > (long)m->height * (long)m->tile_h) return 1; + x0 = (unsigned)b->x / m->tile_w; + y0 = (unsigned)b->y / m->tile_h; + x1 = (unsigned)(b->x + b->w - 1) / m->tile_w; + y1 = (unsigned)(b->y + b->h - 1) / m->tile_h; + for (y = y0; y <= y1; ++y) + for (x = x0; x <= x1; ++x) + if (m->cells[y * m->width + x] & m->solid_mask) return 1; + return 0; +} +int lte_blocked(const lte_tilemap *m, const lte_box *b) +{ + if (!lte_map_valid(m) || !box_valid(b)) return 1; + return blocked(m, b); +} +int lte_move_tiles(const lte_tilemap *m, lte_box *b, short dx, short dy) +{ + lte_box next, edge; + int axis, hit = 0; + if (!lte_map_valid(m) || !box_valid(b) || dx < -256 || dx > 256 || + dy < -256 || dy > 256 || blocked(m, b)) return LTE_MOVE_INVALID; + for (axis = 0; axis < 2; ++axis) { + int delta = axis ? dy : dx; + int direction = delta < 0 ? -1 : 1; + unsigned count = (unsigned)(delta < 0 ? -delta : delta); + while (count--) { + next = *b; + if (axis) next.y = (short)(next.y + direction); + else next.x = (short)(next.x + direction); + edge = next; + if (axis) { + if (direction > 0) edge.y = (short)(edge.y + edge.h - 1); + edge.h = 1; + } else { + if (direction > 0) edge.x = (short)(edge.x + edge.w - 1); + edge.w = 1; + } + if (blocked(m, &edge)) { hit |= axis ? LTE_HIT_Y : LTE_HIT_X; break; } + *b = next; + } + } + return hit; +} +int lte_camera_set(lte_camera *c, short x, short y, short w, short h, + short world_w, short world_h) +{ + short max_x, max_y; + if (!c || w <= 0 || h <= 0 || w > 16384 || h > 16384 || + world_w <= 0 || world_h <= 0 || world_w > 16384 || world_h > 16384) + return 0; + max_x = world_w > w ? (short)(world_w - w) : 0; + max_y = world_h > h ? (short)(world_h - h) : 0; + c->x = x < 0 ? 0 : (x > max_x ? max_x : x); + c->y = y < 0 ? 0 : (y > max_y ? max_y : y); + c->w = w; c->h = h; + return 1; +} +int lte_visible_tiles(const lte_tilemap *m, const lte_camera *c, + lte_tile_visit visit, void *user) +{ + unsigned x0, y0, x1, y1, x, y; + if (!lte_map_valid(m) || !c || !visit || c->x < 0 || c->y < 0 || + c->w <= 0 || c->h <= 0 || c->w > 16384 || c->h > 16384) return 0; + x0 = (unsigned)c->x / m->tile_w; + y0 = (unsigned)c->y / m->tile_h; + x1 = (unsigned)(((long)c->x + c->w - 1) / m->tile_w); + y1 = (unsigned)(((long)c->y + c->h - 1) / m->tile_h); + if (x1 >= m->width) x1 = m->width - 1; + if (y1 >= m->height) y1 = m->height - 1; + for (y = y0; y <= y1; ++y) + for (x = x0; x <= x1; ++x) + visit(user, m->cells[y * m->width + x], + (short)((long)x * (long)m->tile_w - c->x), + (short)((long)y * (long)m->tile_h - c->y), + (short)m->tile_w, (short)m->tile_h); + return 1; +} diff --git a/src/lt_engine_view.c b/src/lt_engine_view.c new file mode 100644 index 0000000..f413bca --- /dev/null +++ b/src/lt_engine_view.c @@ -0,0 +1,70 @@ +/* SPDX-License-Identifier: MIT */ +#include "lt_engine_view.h" +#include +int lte_view_bind(lte_view *v, unsigned mode, + unsigned char LTE_FAR *pixels, unsigned bytes) +{ + lte_view next; + unsigned need; + if (!v || !pixels) return 0; + next.pixels = pixels; next.mode = (unsigned char)mode; + next.height = 200; next.banks = 0; + if (mode == LTE_VIEW_TEXT40 || mode == LTE_VIEW_TEXT80) { + next.width = mode == LTE_VIEW_TEXT40 ? 40 : 80; + next.height = 25; next.stride = next.width * 2; + need = next.stride * next.height; + } else if (mode == LTE_VIEW_TANDY160 || mode == LTE_VIEW_TANDY320) { + next.width = mode == LTE_VIEW_TANDY160 ? 160 : 320; + next.banks = mode == LTE_VIEW_TANDY160 ? 2 : 4; + next.stride = next.width / 2; + need = next.banks * 8192U; + } else return 0; + if (bytes < need) return 0; + *v = next; + return 1; +} +void lte_view_rect(lte_view *v, short x, short y, short w, short h, + unsigned char ink, unsigned char glyph) +{ + long right = (long)x + w, bottom = (long)y + h; + unsigned row, col, x0, y0, x1, y1, offset; + if (!v || !v->pixels || w <= 0 || h <= 0 || right <= 0 || bottom <= 0 || + x >= (short)v->width || y >= (short)v->height) return; + x0 = x < 0 ? 0 : (unsigned)x; + y0 = y < 0 ? 0 : (unsigned)y; + x1 = right > (long)v->width ? v->width : (unsigned)right; + y1 = bottom > (long)v->height ? v->height : (unsigned)bottom; + for (row = y0; row < y1; ++row) { + if (!v->banks) { + offset = row * v->stride + x0 * 2; + for (col = x0; col < x1; ++col) { + v->pixels[offset++] = glyph; v->pixels[offset++] = ink; + } + } else { + unsigned char LTE_FAR *p; + unsigned end; + if (v->banks == 2) + offset = (row & 1U) * 8192U + (row >> 1) * v->stride; + else + offset = (row & 3U) * 8192U + (row >> 2) * v->stride; + p = v->pixels + offset; + ink &= 15; + col = x0; + if (col & 1U) { + p[col / 2] = (unsigned char)((p[col / 2] & 240U) | ink); + ++col; + } + end = x1 & ~1U; + if (col < end) { +#if defined(__WATCOMC__) && defined(__I86__) + _fmemset(p + col / 2, ink * 17U, (end - col) / 2); +#else + memset(p + col / 2, ink * 17U, (end - col) / 2); +#endif + col = end; + } + if (col < x1) + p[col / 2] = (unsigned char)((p[col / 2] & 15U) | (ink << 4)); + } + } +} diff --git a/tests/engine_core.c b/tests/engine_core.c new file mode 100644 index 0000000..a434023 --- /dev/null +++ b/tests/engine_core.c @@ -0,0 +1,167 @@ +/* SPDX-License-Identifier: MIT */ +#include +#include +#include "lt_engine.h" +#define CHECK(v) do { if (!(v)) { printf("FAIL engine line %d\n", __LINE__); return 1; } } while (0) +static char logbuf[256]; +static unsigned used, mutations, recursion_ok; +static lte_object *victim, *child; +static const lte_behavior behavior; +static void record(lte_object *o, char c) +{ + if (used < sizeof(logbuf) - 2) { + logbuf[used++] = c; logbuf[used++] = *(char *)o->user; + } + logbuf[used] = 0; +} +static void awake(lte_world *w, lte_object *o) +{ + (void)w; record(o, 'A'); +} +static void start(lte_world *w, lte_object *o) +{ + (void)w; record(o, 'S'); +} +static void fixed(lte_world *w, lte_object *o) +{ + (void)w; record(o, 'F'); +} +static void update(lte_world *w, lte_object *o) +{ + static char name = 'c'; + record(o, 'U'); + recursion_ok = lte_frame(w, 0) == -1; + if (mutations && *(char *)o->user == 'a') { + lte_remove(w, victim); + child = lte_add(w, &behavior, &name); + mutations = 0; + } +} +static void late(lte_world *w, lte_object *o) +{ + (void)w; record(o, 'L'); +} +static const lte_behavior behavior = {awake, start, fixed, update, late}; +static unsigned fixed_count; +static void count_tick(lte_world *w, lte_object *o) +{ + (void)w; (void)o; ++fixed_count; +} +static const lte_behavior counter = {0, 0, count_tick, 0, 0}; +static unsigned child_ticks, child_awakes, child_starts, chain_ticks; +static void chain_fixed(lte_world *w, lte_object *o) +{ (void)w; (void)o; ++chain_ticks; } +static const lte_behavior chain = {0, 0, chain_fixed, 0, 0}; +static void child_awake(lte_world *w, lte_object *o) +{ (void)o; ++child_awakes; lte_add(w, &chain, 0); } +static void child_start(lte_world *w, lte_object *o) +{ (void)w; (void)o; ++child_starts; } +static void child_fixed(lte_world *w, lte_object *o) +{ (void)w; (void)o; ++child_ticks; } +static const lte_behavior spawned = {child_awake, child_start, child_fixed, 0, 0}; +static void spawn_fixed(lte_world *w, lte_object *o) +{ + if (!w->ticks) { lte_add(w, &spawned, 0); lte_remove(w, o); } +} +static const lte_behavior spawner = {0, 0, spawn_fixed, 0, 0}; +static unsigned visits; +static short first_x, first_y; +static void tile(void *u, unsigned char value, short x, short y, short w, short h) +{ + (void)u; (void)value; (void)w; (void)h; + if (!visits) { first_x = x; first_y = y; } + ++visits; +} +int main(void) +{ + lte_world world, fast, slow; + lte_object objects[3], a[1], b[1], *first; + char names[2] = {'a', 'b'}; + unsigned i, fast_count; + unsigned char cells[16] = {0,0,1,0, 0,0,1,0, 1,1,1,1, 0,0,0,0}; + lte_tilemap map; + lte_box box = {0,0,8,8}, other = {8,0,8,8}, saved; + lte_camera camera, before; + CHECK(!lte_init(&world, objects, 3, 0, 4)); + CHECK(lte_init(&world, objects, 3, 10, 2)); + first = lte_add(&world, &behavior, &names[0]); + victim = lte_add(&world, &behavior, &names[1]); + CHECK(first && victim); + CHECK(lte_frame(&world, 20) == 2); + CHECK(!strcmp(logbuf, "AaAbSaSbFaFbFaFbUaUbLaLb")); + CHECK(recursion_ok); + used = 0; logbuf[0] = 0; mutations = 1; + CHECK(lte_frame(&world, 0) == 0 && child); + CHECK(!strcmp(logbuf, "UaLa")); + CHECK(!lte_add(&world, &behavior, names)); /* removed slot not reused yet */ + CHECK(!lte_remove(&world, victim)); + used = 0; logbuf[0] = 0; + CHECK(lte_frame(&world, 10) == 1); + CHECK(!strcmp(logbuf, "AcScFaFcUaUcLaLc")); + CHECK(lte_add(&world, &behavior, names) == victim); + CHECK(lte_remove(&world, victim)); /* remove pending: never Awake/Start */ + CHECK(lte_remove(&world, first)); + used = 0; logbuf[0] = 0; + CHECK(lte_frame(&world, 0) == 0 && !strcmp(logbuf, "UcLc")); + CHECK(lte_init(&fast, a, 1, 10, 3)); + CHECK(lte_init(&slow, b, 1, 10, 3)); + CHECK(lte_add(&fast, &counter, 0) && lte_add(&slow, &counter, 0)); + fixed_count = 0; + for (i = 0; i < 30; ++i) CHECK(lte_frame(&fast, 10) == 1); + fast_count = fixed_count; fixed_count = 0; + CHECK(lte_frame(&slow, 300) == 3 && slow.debt == 270); + for (i = 0; i < 9; ++i) CHECK(lte_frame(&slow, 0) == 3); + CHECK(fixed_count == fast_count && slow.debt == 0 && slow.ticks == fast.ticks); + CHECK(lte_frame(&slow, 0xffffffffUL) == 3); + CHECK(lte_frame(&slow, 100) == 3 && slow.saturated); + CHECK(lte_init(&world, objects, 3, 10, 3)); + CHECK(lte_add(&world, &spawner, 0)); + for (i = 0; i < 3; ++i) { + CHECK(lte_frame(&world, 10) == 1); + CHECK(lte_frame(&world, 0) == 0); + CHECK(lte_frame(&world, 0) == 0); + } + CHECK(child_ticks == 2 && child_awakes == 1 && child_starts == 1); + CHECK(chain_ticks == 1); + child_ticks = child_awakes = child_starts = chain_ticks = 0; + CHECK(lte_init(&world, objects, 3, 10, 3)); + CHECK(lte_add(&world, &spawner, 0)); + CHECK(lte_frame(&world, 30) == 3); + CHECK(child_ticks == 2 && child_awakes == 1 && child_starts == 1); + CHECK(chain_ticks == 1); + map.cells = cells; map.width = map.height = 4; + map.tile_w = map.tile_h = 8; map.solid_mask = 1; + CHECK(lte_map_valid(&map)); + CHECK(!lte_overlap(&box, &other)); + other.x = 7; CHECK(lte_overlap(&box, &other)); + CHECK(lte_move_tiles(&map, &box, 32, 32) == (LTE_HIT_X | LTE_HIT_Y)); + CHECK(box.x == 8 && box.y == 8); /* cannot tunnel through 1-cell wall */ + CHECK(lte_move_tiles(&map, &box, -32, -32) == (LTE_HIT_X | LTE_HIT_Y)); + CHECK(box.x == 0 && box.y == 0); + saved = box; + CHECK(lte_move_tiles(&map, &box, 257, 0) == LTE_MOVE_INVALID); + CHECK(!memcmp(&saved, &box, sizeof(box))); + box.x = 16; saved = box; + CHECK(lte_move_tiles(&map, &box, -1, 0) == LTE_MOVE_INVALID); + CHECK(!memcmp(&saved, &box, sizeof(box))); + box.x = -1; CHECK(lte_blocked(&map, &box)); + CHECK(lte_camera_set(&camera, -10, 99, 16, 16, 32, 32)); + CHECK(camera.x == 0 && camera.y == 16); + CHECK(lte_camera_set(&camera, 99, 99, 64, 64, 32, 32)); + CHECK(camera.x == 0 && camera.y == 0); + before = camera; + CHECK(!lte_camera_set(&camera, 0, 0, 0, 10, 32, 32)); + CHECK(!memcmp(&camera, &before, sizeof(camera))); + CHECK(lte_camera_set(&camera, 3, 7, 16, 16, 32, 32)); + CHECK(lte_visible_tiles(&map, &camera, tile, 0)); + CHECK(visits == 9 && first_x == -3 && first_y == -7); + camera.x = 100; visits = 0; + CHECK(lte_visible_tiles(&map, &camera, tile, 0) && visits == 0); + map.width = 65535U; CHECK(!lte_map_valid(&map)); + printf("engine passed lifecycle, mutation, foreground debt, collision, camera; " + "world=%u object=%u box=%u camera=%u map=%u behavior=%u bytes\n", + (unsigned)sizeof(world), (unsigned)sizeof(objects[0]), + (unsigned)sizeof(box), (unsigned)sizeof(camera), + (unsigned)sizeof(map), (unsigned)sizeof(behavior)); + return 0; +} diff --git a/tests/engine_dos.c b/tests/engine_dos.c new file mode 100644 index 0000000..cb5a13a --- /dev/null +++ b/tests/engine_dos.c @@ -0,0 +1,44 @@ +/* SPDX-License-Identifier: MIT */ +#include +#include +#include +#include "lt_engine_dos.h" +static void snapshot(unsigned char state[6]) +{ + union REGS r; + memset(&r, 0, sizeof(r)); r.h.ah = 15; + int86(0x10, &r, &r); state[0] = r.h.al; state[1] = r.h.bh; + memset(&r, 0, sizeof(r)); r.h.ah = 3; r.h.bh = state[1]; + int86(0x10, &r, &r); + state[2] = r.h.ch; state[3] = r.h.cl; state[4] = r.h.dh; state[5] = r.h.dl; +} +int main(void) +{ + lte_dos_video v = LTE_DOS_VIDEO_INITIALIZER; + unsigned char before[6], after[6]; + unsigned modes[4] = {1, 3, 8, 9}, i; + unsigned short vector[4]; + const volatile unsigned short __far *ivt = + (const volatile unsigned short __far *)MK_FP(0, 0x20); + int pass = 1; + for (i = 0; i < 4; ++i) vector[i] = ivt[i]; /* IRQ0 and IRQ1 */ + snapshot(before); + if (lte_dos_video_open(&v, 4) || v.active) pass = 0; + for (i = 0; i < 4 && pass; ++i) { + if (!lte_dos_video_open(&v, modes[i])) { pass = 0; break; } + snapshot(after); + if (after[0] != modes[i] || after[1] != 0) pass = 0; + if (lte_dos_video_open(&v, modes[i])) pass = 0; + lte_view_rect(&v.view, -2, -2, 20, 20, 15, '@'); + lte_dos_video_close(&v); + lte_dos_video_close(&v); + snapshot(after); + if (memcmp(before, after, sizeof(before)) || v.active || v.view.pixels) pass = 0; + } + for (i = 0; i < 4; ++i) if (vector[i] != ivt[i]) pass = 0; + lte_dos_video_close(&v); + printf("engine DOS mode/page/cursor restore, double-open/close, invalid mode, " + "IRQ0/1 unchanged: %s; video=%u bytes\n", pass ? "PASS" : "FAIL", + (unsigned)sizeof(v)); + return pass ? 0 : 1; +} diff --git a/tests/engine_view.c b/tests/engine_view.c new file mode 100644 index 0000000..b59677d --- /dev/null +++ b/tests/engine_view.c @@ -0,0 +1,42 @@ +/* SPDX-License-Identifier: MIT */ +#include +#include +#include "lt_engine_view.h" +#define CHECK(v) do { if (!(v)) { printf("FAIL engine view line %d\n", __LINE__); return 1; } } while (0) +/* Far: the 32 KiB fixture must not consume DOS small-model DGROUP. */ +static unsigned char LTE_FAR pixels[32770U]; +int main(void) +{ + lte_view view, saved; + unsigned i; + CHECK(lte_view_bind(&view, 8, pixels + 1, 16384)); + saved = view; + CHECK(!lte_view_bind(&view, 8, pixels + 1, 16383)); + CHECK(!memcmp(&saved, &view, sizeof(view))); + CHECK(!lte_view_bind(&view, 4, pixels + 1, 32768U)); + for (i = 0; i < 32770U; ++i) pixels[i] = 0x5a; + lte_view_rect(&view, 0, 0, 160, 200, 3, 0); + CHECK(pixels[1] == 0x33 && pixels[8193] == 0x33); + CHECK(pixels[1 + 99 * 80] == 0x33); + CHECK(pixels[1 + 8192 + 99 * 80 + 79] == 0x33); + CHECK(pixels[0] == 0x5a && pixels[16385] == 0x5a); + CHECK(pixels[1 + 8000] == 0x5a); /* bank padding untouched */ + lte_view_rect(&view, 1, 0, 2, 1, 14, 0); + CHECK(pixels[1] == 0x3e && pixels[2] == 0xe3); + lte_view_rect(&view, -2, -2, 3, 3, 7, 0); + CHECK(pixels[1] == 0x7e); + CHECK(lte_view_bind(&view, 9, pixels + 1, 32768U)); + lte_view_rect(&view, 319, 199, 30000, 30000, 15, 0); + CHECK(pixels[1 + 3 * 8192 + 49 * 160 + 159] == 0x5f); + CHECK(pixels[32769U] == 0x5a); + CHECK(lte_view_bind(&view, 3, pixels + 1, 4000)); + lte_view_rect(&view, 0, 0, 80, 25, 0x1e, 219); + CHECK(pixels[1] == 219 && pixels[2] == 0x1e); + CHECK(pixels[3999] == 219 && pixels[4000] == 0x1e); + CHECK(lte_view_bind(&view, 1, pixels + 1, 2000)); + lte_view_rect(&view, 39, 24, 2, 2, 0x2f, '@'); + CHECK(pixels[1999] == '@' && pixels[2000] == 0x2f); + CHECK(pixels[2001] == 219); /* clip at 40x25 */ + printf("engine view passed native 8/9 banks, nibble edges, text 1/3, clipping; view=%u bytes\n", (unsigned)sizeof(view)); + return 0; +} diff --git a/tools/build.py b/tools/build.py index 98ae005..de0bb5a 100644 --- a/tools/build.py +++ b/tools/build.py @@ -8,6 +8,8 @@ ROOT = Path(__file__).resolve().parents[1] TESTS = { + 'engine': ('engine_core', ['lt_engine']), + 'engview': ('engine_view', ['lt_engine_view']), 'stream': ('stream_core', ['lt_chip', 'lt_audio', 'lt_sfx', 'lt_stream']), 'smscheck': ('sms_boot_core', ['lt_chip', 'lt_sms_boot']), 'chip': ('chip_core', ['lt_chip']), @@ -24,6 +26,14 @@ } # DOS-only demos of the platform services: name -> sources. DOS_DEMOS = { + 'ENGDOS': ['tests/engine_dos.c', 'src/lt_engine_view.c', + 'adapters/lt_engine_dos.c'], + 'ENGDEMO': ['examples/engine_demo.c', 'src/lt_engine.c', + 'src/lt_engine_view.c', 'adapters/lt_engine_dos.c', + 'src/lt_time.c', 'adapters/lt_time_dos.c'], + 'TXTDEMO': ['examples/engine_text_demo.c', 'src/lt_engine.c', + 'src/lt_engine_view.c', 'adapters/lt_engine_dos.c', + 'src/lt_time.c', 'adapters/lt_time_dos.c'], 'STRMDEMO': ['examples/stream_demo.c', 'src/lt_chip.c', 'src/lt_audio.c', 'src/lt_sfx.c', 'src/lt_stream.c', 'adapters/lt_dos.c'], 'STRMBEN': ['tests/stream_bench.c', 'src/lt_chip.c', 'src/lt_audio.c', diff --git a/tools/check_engine_package.py b/tools/check_engine_package.py new file mode 100644 index 0000000..1dbcde8 --- /dev/null +++ b/tools/check_engine_package.py @@ -0,0 +1,41 @@ +"""Verify the optional engine DOS download against source and native receipts.""" +from pathlib import Path +import hashlib +import json +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +DIRECTORY = ROOT / 'examples/engine' +manifest = json.loads((DIRECTORY / 'DOS-BUILD.json').read_text()) +native = json.loads((DIRECTORY / 'NATIVE.json').read_text()) +sha = lambda data: hashlib.sha256(data).hexdigest() +for name, digest in manifest['source_lf_sha256'].items(): + assert sha((ROOT / name).read_bytes().replace(b'\r\n', b'\n')) == digest, name +assert sha((DIRECTORY / 'NATIVE.json').read_bytes().replace(b'\r\n', b'\n')) == \ + manifest['native_lf_sha256'] +assert native['complete'] and native['owned_child_stopped'] and native['lease_released'] +for run in ('CORE', 'VIEW', 'VIDEO'): + assert 'FAIL' not in native[run] and ('passed' in native[run] or 'PASS' in native[run]), run +for run in ('G320', 'G160', 'T80', 'T40', 'SLOW'): + assert 'ticks=300' in native[run] and 'debt=0' in native[run], run + assert 'xy=330,176 PASS' in native[run], run +archive = DIRECTORY / 'ENGINE.ZIP' +assert sha(archive.read_bytes()) == manifest['zip_sha256'], 'ZIP hash' +with zipfile.ZipFile(archive) as package: + assert package.testzip() is None + assert set(package.namelist()) == set(manifest['members']), 'ZIP members' + for name, expected in manifest['members'].items(): + data = package.read(name) + assert len(data) == expected['bytes'] and sha(data) == expected['sha256'], name + for name in ('ENGINE.EXE', 'ENGVIEW.EXE', 'ENGDOS.EXE', 'ENGDEMO.EXE', 'TXTDEMO.EXE'): + data = package.read(name) + assert data[:2] == b'MZ', name + assert sha(data) == native[name]['sha256'], name + ': native hash' + assert len(data) == native[name]['bytes'], name + ': native size' + for name, source in manifest['document_members'].items(): + assert package.read(name).replace(b'\r\n', b'\n') == \ + (ROOT / source).read_bytes().replace(b'\r\n', b'\n'), name + for name in ('README.TXT', 'RUNCHECK.BAT'): + remainder = package.read(name).replace(b'\r\n', b'') + assert b'\r' not in remainder and b'\n' not in remainder, name + ': DOS CRLF' +print('PASS qualified engine DOS package: source, native receipt, ZIP, EXEs and docs')