diff --git a/.github/workflows/solver-web.yml b/.github/workflows/solver-web.yml
new file mode 100644
index 0000000..58db077
--- /dev/null
+++ b/.github/workflows/solver-web.yml
@@ -0,0 +1,82 @@
+name: Browser solver package
+
+on:
+ push:
+ tags: ['solver-web-v*']
+ pull_request:
+ paths:
+ - 'packages/solver-web/**'
+ - 'tools/build_solver_web.py'
+ - 'tests/test_solver_web.mjs'
+ - 'tests/test_web_dynamic.c'
+ - 'tests/web_yang_zhang.json'
+ - 'include/wang/**'
+ - 'src/**'
+ - '.github/workflows/solver-web.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ - name: Install the pinned Emscripten SDK outside the checkout
+ run: |
+ git clone --depth 1 --branch 4.0.23 https://github.com/emscripten-core/emsdk.git "$RUNNER_TEMP/emsdk"
+ "$RUNNER_TEMP/emsdk/emsdk" install 4.0.23
+ "$RUNNER_TEMP/emsdk/emsdk" activate 4.0.23
+ - name: Build versioned package and check tag
+ env:
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ if [ "$REF_TYPE" = tag ]; then
+ python3 -c 'import json, os; assert os.environ["REF_NAME"] == "solver-web-v" + json.load(open("packages/solver-web/package.json"))["version"]'
+ python3 tools/build_solver_web.py --release --emcc "$RUNNER_TEMP/emsdk/upstream/emscripten/emcc"
+ else
+ python3 tools/build_solver_web.py --emcc "$RUNNER_TEMP/emsdk/upstream/emscripten/emcc"
+ fi
+ - name: Check both compiled engines against exhaustive enumeration
+ run: |
+ for package in build/solver-web/*/; do
+ node tests/test_solver_web.mjs "$package"
+ done
+ - name: Check browser engine memory safety and allocation failures
+ run: |
+ cc -Iinclude -std=c17 -Wall -Wextra -Werror -O1 -g -fsanitize=address,undefined tests/test_web_dynamic.c -o build/test_web_dynamic
+ build/test_web_dynamic
+ - name: Retain release archive briefly
+ uses: actions/upload-artifact@v4
+ with:
+ name: solver-web
+ path: |
+ build/solver-web/*.tar.gz
+ build/solver-web/*.sha256
+ retention-days: 1
+
+ release:
+ if: startsWith(github.ref, 'refs/tags/solver-web-v')
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: solver-web
+ path: package
+ - name: Publish package assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ github.ref_name }}
+ run: |
+ gh release create "$TAG" package/* --verify-tag --title "Browser solver ${TAG#solver-web-v}" --generate-notes
diff --git a/Makefile b/Makefile
index cc1ddbb..217154b 100644
--- a/Makefile
+++ b/Makefile
@@ -126,6 +126,10 @@ serial: $(SERIAL_LIBRARY)
shared: $(SHARED_LIBRARY)
+.PHONY: solver-web
+solver-web:
+ $(PYTHON) tools/build_solver_web.py
+
openmp: $(OPENMP_LIBRARY)
$(SERIAL_LIBRARY): $(SERIAL_OBJECTS) | $(LIB_DIR)
diff --git a/README.md b/README.md
index a94d511..dba5a7b 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,12 @@ it, and checking that the different views of the instance agree.
[Evidence](https://xtraid.github.io/tiling-foundry/evidence/) ·
[Run dossiers](https://xtraid.github.io/tiling-foundry/run-dossiers/)
+**[Open Wang Studio in your browser →](https://xtraid.github.io/wang-studio/)**
+
+Build your own regions and tilesets in the independent research editor.
+Both C solvers run in your browser; no download or installation is required.
+Square solving is available now.
+
## Why this repository exists
Yang and Zhang proved in 2024 that tiling finite simply connected regions is
@@ -192,6 +198,15 @@ the data flow and component boundaries. The
[worked example](https://xtraid.github.io/tiling-foundry/worked-example/)
follows a SAT instance from its source formula to the checked tiling.
+## Interactive research companion
+
+[Wang Studio](https://xtraid.github.io/wang-studio/) is an independent editor
+for subsequent tiling research, with its own repository and website. It consumes
+the [versioned browser solver package](packages/solver-web/README.md) built here
+from the reference and optimized C engines. See the
+[integration notes](docs/plans/wang_studio.md). Studio is distinct from the
+artifact renderer and requires no Python solver at runtime.
+
## Small standalone harness
[`harness_wangz3/`](harness_wangz3/) contains a small reimplementation of the
diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html
index 200154c..f065359 100644
--- a/docs/_includes/footer.html
+++ b/docs/_includes/footer.html
@@ -4,6 +4,8 @@
Run the demo
Follow the technical tour
diff --git a/docs/plans/wang_studio.md b/docs/plans/wang_studio.md
new file mode 100644
index 0000000..83569fc
--- /dev/null
+++ b/docs/plans/wang_studio.md
@@ -0,0 +1,54 @@
+# Wang Studio: independent research editor
+
+Wang Studio is an independent project for the research following Tiling Foundry,
+with its own repository and website. It consumes Foundry's versioned solver
+package for authoring tilesets, constructing finite regions and exploring search.
+
+The browser package is maintained under `packages/solver-web/` in Foundry and
+contains the C reference and optimized engines compiled to WebAssembly. Its
+documentation is the current package contract. Python/Z3 is not distributed
+to the editor. The local Python connector described below remains an optional
+development comparison path. Foundry's Pages link to Studio; Studio is not
+hosted in the Foundry source repository.
+
+It is distinct from `renderer/`, which produces figures from verified
+Foundry artifacts. Studio owns interaction and experiments; Foundry continues
+to own native propagation, search, verification and scientific pipelines.
+
+The first local connector consumes `libwang.so` through
+`wang_solve_optimized_tileset`: the **same optimized core** accepts a runtime
+square tileset of 1–32 tiles with colors 0–15. It supports initial domains and
+metrics. Canonical trace formats are not accepted for custom tilesets.
+The existing 23-tile APIs and the Yang–Zhang pipeline remain available.
+
+The runtime verifier is `wang_verify_tiling_with_tileset`. Both interfaces
+borrow their input arrays. The result lifecycle is unchanged:
+`wang_solve_result_destroy` releases returned domains.
+
+Studio runs solves in worker processes for cancellation/deadlines, checks
+solutions independently, and treats timeout as distinct from UNSAT. It does
+not copy the solver implementation or use Z3 as a runtime fallback.
+
+Current local companion checkout: `~/Projects/wang-studio`. Its README
+contains startup instructions and the connector's resource limits. Native
+adjacency for hexagons and triangles is future work; Studio's support for
+editing those geometries does not imply that this square-grid API solves them.
+
+## Incremental enumeration
+
+`wang_enumerate_optimized_tileset` shares the optimized search core. Each
+verified solution is passed to a borrowed-domain visitor. Returning true
+continues via trail rollback; false stops. A visitor may block, retaining the
+DFS stack, to implement interactive next-solution requests. Return codes are
+`0` for exhaustive completion, `1` for visitor-requested stop, and `-1` for error.
+
+Enumeration deduplicates identical edge tuples within each initial cell domain.
+It disables the single-solution shortcut that arbitrarily picks one tile for an
+isolated cell. The canonical decision solver and its trace formats retain their
+previous behavior. The caller owns the count, so no fixed-width counter can
+silently overflow inside the core.
+
+Studio's worker counts with Python integers, publishes throttled progress
+atomically, and waits for next/count commands inside the native visitor. Stop
+and deadlines retain only a lower bound; exhaustive completion publishes the
+exact total. Displayed solutions do not replace the original fixed constraints.
diff --git a/include/wang/solver.h b/include/wang/solver.h
index 7b51e07..1c68506 100644
--- a/include/wang/solver.h
+++ b/include/wang/solver.h
@@ -130,6 +130,65 @@ WangSolveStatus wang_solve_optimized(
WangSolveResult *out_result
);
+/* Runtime square-grid tilesets use the same optimized search mechanisms.
+ * Initial integration limit: 1..32 tiles, colors 0..COLOR_COUNT-1.
+ * Tile position is its ID; generalized metadata is ignored. No rotations.
+ * Domain bit i refers to tileset[i], including bit 31 for a 32-tile set.
+ * Supports initial_domains and COLLECT_METRICS; canonical trace formats are
+ * intentionally not accepted for non-canonical tilesets. Borrowed inputs.
+ */
+#define WANG_RUNTIME_MAX_TILES 32u
+WangSolveStatus wang_solve_optimized_tileset(
+ const Region *region,
+ const WangTile *tileset,
+ size_t tileset_count,
+ const WangSolverOptions *options,
+ WangSolveResult *out_result
+);
+
+/* Enumerate distinct edge-color configurations using the optimized DFS.
+ * The visitor receives borrowed singleton domains and returns true to continue
+ * or false to stop. It may block to pause without losing the search stack.
+ * Duplicate edge tuples are collapsed per cell, respecting initial domains.
+ * Return: -1 error, 0 exhaustive completion, 1 visitor-requested stop.
+ * The caller owns any counter (so counting is not limited to uint64_t).
+ * No rotations. Inputs must remain immutable until this call returns.
+ */
+typedef bool (*WangSolutionVisitor)(
+ const uint32_t *domains, size_t cell_count,
+ const WangSolverMetrics *metrics, void *userdata
+);
+int wang_enumerate_optimized_tileset(
+ const Region *region, const WangTile *tileset, size_t tileset_count,
+ const WangSolverOptions *options, WangSolutionVisitor visitor,
+ void *userdata, WangSolverMetrics *metrics
+);
+
+/* Same runtime enumeration contract using the reference mechanisms. */
+int wang_enumerate_serial_tileset(
+ const Region *region, const WangTile *tileset, size_t tileset_count,
+ const WangSolverOptions *options, WangSolutionVisitor visitor,
+ void *userdata, WangSolverMetrics *metrics
+);
+
+/* Explicit reciprocal cell/side adjacency. -1 is an exterior side; otherwise
+ * neighbors[i * side_count + d] is the neighboring cell, and neighbor_sides
+ * gives its incident side. Dense edges are tile-major; boundary uses COLOR_NONE.
+ * Borrowed inputs, 1..6 sides, 1..4096 cells, 1..32 tiles. No rotations.
+ * Shares propagation, MRV, trail and DFS with square enumeration. */
+#define WANG_GRAPH_MAX_SIDES 6u
+typedef struct {
+ size_t cell_count;
+ size_t side_count;
+ const int32_t *neighbors;
+ const uint8_t *neighbor_sides;
+ const uint8_t *boundary;
+ const uint8_t *edges;
+} WangGraph;
+int wang_enumerate_graph(const WangGraph *graph, size_t tile_count,
+ const WangSolverOptions *options, bool reference, WangSolutionVisitor visitor,
+ void *userdata, WangSolverMetrics *metrics);
+
/* Release the owned snapshot and reset every field. Accepts NULL. */
void wang_solve_result_destroy(WangSolveResult *result);
diff --git a/include/wang/verify.h b/include/wang/verify.h
index 8156076..caf3cdd 100644
--- a/include/wang/verify.h
+++ b/include/wang/verify.h
@@ -28,4 +28,10 @@ WangVerifyStatus wang_verify_tiling(
size_t tile_count
);
+/* Independent verification against an explicitly supplied square tileset. */
+WangVerifyStatus wang_verify_tiling_with_tileset(
+ const Region *region, const TileId *tiles, size_t tile_count,
+ const WangTile *tileset, size_t tileset_count
+);
+
#endif /* WANG_VERIFY_H */
diff --git a/packages/solver-web/README.md b/packages/solver-web/README.md
new file mode 100644
index 0000000..95ad7f8
--- /dev/null
+++ b/packages/solver-web/README.md
@@ -0,0 +1,126 @@
+# Tiling Foundry browser solver
+
+This package is built and owned by Tiling Foundry. Independent applications
+consume the versioned artifacts without copying the native solver source.
+It runs C search in a dedicated Web Worker; native targets remain unchanged.
+No Python service, npm runtime dependencies, shared memory, cross-origin
+isolation headers, or paid compute service is required.
+
+## Build
+
+Activate Emscripten **4.0.23**, then run from the Foundry root:
+
+```sh
+python tools/build_solver_web.py
+```
+
+The archive and its SHA-256 are written to `build/solver-web/`. Development
+versions include a digest of the actual source inputs; the manifest also records
+the Git revision and whether the checkout was dirty. `--release` requires a
+clean checkout and Emscripten 4.0.23, and uses the version in
+`packages/solver-web/package.json`. Archive timestamps, ownership and permissions
+are normalized; rebuilding the same revision with that SDK yields identical bytes.
+
+## Consume
+
+Serve the entire extracted package directory over HTTP(S). Keep its relative
+paths intact, including the Worker, JavaScript loader, Wasm and manifest.
+
+```js
+import {Solver, capabilities} from './vendor/foundry/client.mjs';
+const solver = new Solver();
+const engine = await capabilities();
+const {id} = await solver.start({
+ geometry: 'square', solver: 'optimized', palette: ['#ffffff'], tiles: [[0, 0, 0, 0]],
+ cells: [{id: '0,0', required: [null, null, null, null]}],
+ enumeration_mode: 'step', timeout_seconds: 30,
+});
+// Poll solver.get(id). When paused: solver.resume(id, 'next' | 'count').
+// solver.cancel(id) terminates the Worker; solver.close() closes all jobs.
+```
+
+One live search per Solver instance. Statuses: `running`, `paused`, `complete`,
+`cancelled`, `timeout`, `expired`, `error`. Only exhaustive `complete` yields
+`exact: true`. Counts are arbitrary-precision decimal strings; metrics are
+decimal strings too. Duplicate edge tuples are counted once, subject to initial
+domains. Palette indices are symbolic identities, even when RGB values repeat.
+
+Supported geometries: `square`, `tri-up`, `tri-side`, `hex-flat`, `hex-point`.
+Square edges are `(N,E,S,W)`, with fixed orientations. Tile cardinality is runtime-sized (`max_tiles: null`). Resource limits remain
+65536 used colors, 65536 active cells and 65536 palette entries. Sparse square
+regions use active-cell graphs when their bounding box exceeds the native adapter
+budget, so no bounding-box size limit applies. `fixed` optionally pins an exact edge tuple. `required` constrains each
+side; `null` means unconstrained. Negative coordinates, holes and disconnected
+inputs are supported, but applications may impose stricter research profiles.
+
+`solver` selects `optimized` (default) or `reference`. Within the native limits
+(up to 32 tiles, 16 used colors and 4096 cells), both execute the shared
+Foundry C core with their respective mechanism flags. Inputs exceeding those
+limits use the same browser-only dynamic engine for either selection; they do
+not claim the native optimized mechanisms. Python/Z3 is not part of
+this package. The two native reference/oracle paths remain available in Foundry.
+
+The package validates input and independently checks published assignments in
+JavaScript. Native Foundry verifies every solution before invoking the visitor.
+Counting retains a single snapshot and a counter, not a list of solutions.
+
+## Pause, stop and deadlines
+
+Asyncify suspends the existing C callback for interactive stepping, preserving
+the DFS stack and trail. Counting yields to the Worker event loop at throttled
+progress updates. Asyncify has a
+runtime cost; native and browser performance must be benchmarked separately.
+
+The controlling thread enforces a deadline per active segment, including when
+the native search has found no solution. Progress never resets that deadline.
+The default active deadline is 600 seconds. `timeout_seconds: 0` disables it.
+Pause uses `idle_timeout_seconds` (default 0, no expiry); Next/Count starts a new
+active segment. Both timers accept up to 2147483 seconds.
+Stop/timeout terminates the Worker and retains the last published lower bound.
+Browser timer throttling can delay enforcement in background tabs. A closed
+page ends its computations; there is no server-side persistence.
+
+Triangles use IDs `i,j,half` (half 0 or 1) and three sides; hexagons use IDs
+`i,j` and six sides. Edge order follows Studio's polygon vertex order. Triangle
+neighbors share the same side index (including inverted cells); hexagonal
+neighbors use `(side+3)%6`. Variants rotate the lattice, without implicit tile
+rotations. The input adapter builds reciprocal cell/side arcs and calls
+the private browser bridge. Small domains share the existing C core; larger
+domains use the private dynamic propagation, MRV, trail and iterative DFS.
+Graph regions use dense active cells, without the square bounding-box limit.
+Generalized pieces compiled into atomic edge tuples are supported; piece metadata
+is not interpreted by the solver.
+
+Consumers should retain `manifest.json` with experiment results, pin releases,
+and update only between searches. Integrity hashes identify artifacts; a hash
+does not establish trust in an otherwise untrusted download source.
+
+## Domain and color ABI (version 3)
+
+The public JavaScript API stays at version 1: input edge tuples and output numeric
+array indices do not change. The matched Worker/Wasm pair uses private ABI 3,
+recorded as `wasm_abi_version` in the manifest. Do not mix package artifacts.
+`foundry_enumerate_graph` takes cell-major `uint32_t` domains with
+`ceil(tile_count / 32)` words per cell. Tile `t` is bit `t % 32` in word
+`floor(t / 32)`; unused tail bits must be zero. Callback domains have the same
+layout and are borrowed until the callback returns. Counts remain cell counts,
+not word counts. The Worker copies snapshots before resuming and refreshes heap
+views after allocations/growth. It frees input buffers in `finally`.
+The historical rectangular entry point retains its single-word contract; the
+adapter routes squares larger than 32 tiles through the general graph entry.
+The graph entry adds a `wide_colors` argument. When true, edge and boundary
+arrays contain `uint32_t` color IDs, with `UINT32_MAX` as the unconstrained
+boundary sentinel. Otherwise byte arrays retain their previous representation.
+Color support uses runtime-sized stamp arrays, not a fixed-width color mask.
+No tile ID is narrowed to the native `uint8_t TileId` in the dynamic engine.
+
+`dynamic.c` is linked only by `build_solver_web.py`. Native sources, headers,
+canonical tiles, builder and Makefile are unchanged. Root restrictions and
+duplicate-tuple elimination precede arc consistency. Search records changed
+words on an undo trail, selects MRV cells, and stores decisions on a heap stack.
+Every witness is checked in C and published snapshots are checked independently
+in JavaScript. Allocation/size failures return `-1`, never exhaustive UNSAT;
+Wasm malloc returns null on exhaustion. The Worker reports an error and releases
+its inputs. Current Wasm linear-memory ceiling is 2 GiB (allocation can fail earlier); Stop/deadline terminates the
+Worker even while search has no witness. Large-cardinality performance is not
+promised to match the specialized native optimized solver.
diff --git a/packages/solver-web/VALIDATION.md b/packages/solver-web/VALIDATION.md
new file mode 100644
index 0000000..860a562
--- /dev/null
+++ b/packages/solver-web/VALIDATION.md
@@ -0,0 +1,104 @@
+# Runtime-sized WebAssembly domains
+
+The former browser limit came from one `uint32_t` per cell, fixed compatibility
+arrays in `src/solver/solver_serial.c`, the browser C bridge, JavaScript shifts
+by tile ID, and Studio's active-tile validation. The canonical Yang–Zhang tiles
+and native solvers still use their existing representations.
+
+## Scope and contract
+
+The web build continues to share `core/tile.c`, `core/region.c`,
+`solver/solver_serial.c`, `solver/byte_support_table.c`, both trace modules and
+`verify/verify_tiling.c` with native builds. None of those files, native headers,
+the builder or native Makefile changed. Only the web build links the new
+`packages/solver-web/dynamic.c`. Inputs within the native limits (32 tiles, 16 colors, 4096 cells) take the
+existing native-core path. Larger inputs use a private graph solver for both browser selections;
+`optimized` above 32 does not imply native byte-support/MRV-index optimizations.
+
+Domains have `ceil(tile_count/32)` words per cell. Every shift is modulo 32 and
+tail bits are validated. Array multiplication and trail growth are checked;
+allocation failure returns an error, never UNSAT. The engine performs root
+restriction, allowed duplicate-tuple elimination, fixed-point arc consistency,
+MRV, iterative DFS and rollback of changed words. Every complete assignment is
+checked against original domains, boundaries and reciprocal adjacency before
+publication. Decisions live on the heap, avoiding recursion limits.
+
+The public JS API remains version 1 (edge arrays in, numeric indices out).
+Private Wasm ABI 3 transfers multiword domains and optional 32-bit colors; the updated Worker decodes them
+without converting IDs to `uint8_t` or truncating them to JavaScript bitmasks.
+Square instances above 32 tiles use dense graph adjacency. Buffer ownership,
+Asyncify pause/resume and Worker Stop/deadline behavior are unchanged. See the
+package README for the precise ABI and resource budgets.
+
+## Initial multiword validation on 2026-10-12
+
+- `make -j4 check`: passed C checks, shared/serial/OpenMP builds, benchmark smoke,
+ solver comparisons and all 252 Python tests. Native source hashes also match
+ the source hashes of Studio's previous installed package.
+- `node tests/test_solver_web.mjs PACKAGE`: passed 500 existing randomized
+ differential instances and 100 new 65-tile instances against an independent
+ exhaustive polygon oracle, for both solver selections.
+- Cardinalities 23, 31, 32, 33, 64, 65, 96, 97, 257 and 1025: passed SAT/UNSAT,
+ enumeration, highest-ID pins and asynchronous pause in all five geometries.
+- The actual canonical 23-tile fixture (emitted from `TILESET`) passed SAT and
+ UNSAT comparisons. Counts are independently compared, not just SAT flags.
+- Actual Wasm odd cycles that are arc-consistent but UNSAT, and branches that
+ fail before a later SAT witness, passed across word boundaries. A failed
+ allocation above the Wasm memory ceiling returned null; a subsequent small allocation succeeded.
+- `tests/test_web_dynamic.c` compiled with AddressSanitizer and UBSan passed,
+ including leak checking, every reached allocation failure, malformed tail
+ bits, odd/even cycles and repeated trail reallocations on a 1024-cell ring.
+- Studio's Chromium tests passed actual Add/edit controls, JSON download/import,
+ 23/32/33/64/65 tiles, both solver selections, SAT/UNSAT, highest-ID rendering
+ and canvas export in all five geometries. Catalogs of 65 generalized atoms
+ also survived serialization and solved through Wasm. Runtime ABI 2 is asserted
+ in the UI session; console errors and native API calls fail the harness.
+- Studio's six Node editor/geometry/generalized suites and 14 Python tests passed.
+
+The browser-only memory test is wired into the package CI. Reproduce it with:
+
+```sh
+cc -Iinclude -std=c17 -Wall -Wextra -Werror -O1 -g \
+ -fsanitize=address,undefined tests/test_web_dynamic.c -o build/test_web_dynamic
+build/test_web_dynamic
+```
+
+## Remaining resource limits
+
+There is no tileset-cardinality cap in the web API or engine. Available address
+space/memory, a 2 GiB Wasm ceiling, 65536 used colors/palette entries, 65536 active
+cells and configurable session deadlines still apply. Color support uses dynamic
+stamp arrays; the wide boundary sentinel is UINT32_MAX, so 255 remains a color. Large-domain search
+is not promised to match the specialized small-domain optimized engine.
+Studio retains its 5 MB import budget and its separate 32-cell limit per single
+generalized piece; multiple pieces can contribute more than 32 active atoms.
+Native runtime-tileset APIs and Studio's optional native connector retain 32.
+Validation is local Chromium/static hosting; no public-site deployment was made.
+
+## Small-tileset benchmark
+
+`node tests/benchmark_solver_web.mjs OLD_PACKAGE NEW_PACKAGE`, Node 26.8.2,
+Emscripten 4.0.23, 2x2 regions, 200 warmups, nine alternating samples of 1000
+complete enumerations per engine. Baseline is Studio's previously pinned
+`0.1.0-dev.e74e7f2776b9`. The final web build enables link-time optimization
+(`-flto`) only in `build_solver_web.py`; it removed a 9–14% slowdown observed in
+intermediate non-LTO builds. Native compiler flags remain unchanged.
+
+| Case | Old median ms / 1000 | New median ms / 1000 | New / old |
+| --- | ---: | ---: | ---: |
+| Yang–Zhang 23 Reference | 152.35 | 145.77 | 0.957 |
+| Yang–Zhang 23 Optimized | 162.25 | 160.70 | 0.990 |
+| 32 Reference | 880.74 | 846.74 | 0.961 |
+| 32 Optimized | 887.11 | 866.06 | 0.976 |
+
+Counts matched exactly: 608 and 4096 configurations per enumeration. No
+regression was observed in these measured cases; this is a local microbenchmark,
+not a guarantee for all workloads or browser engines.
+
+## Color and release consolidation
+
+The current regression suite additionally covers 17, 33, 256, 257 and 1025
+colors in all five geometries, adjacent SAT/UNSAT, high palette indices and
+sparse square regions. Native sanitizer tests cover wide colors through 65535,
+boundary sentinel separation and allocation failures. Release archives normalize
+tar/gzip metadata and require the pinned Emscripten 4.0.23 toolchain.
diff --git a/packages/solver-web/bridge.c b/packages/solver-web/bridge.c
new file mode 100644
index 0000000..dc30ffb
--- /dev/null
+++ b/packages/solver-web/bridge.c
@@ -0,0 +1,75 @@
+/* Browser ABI only. Propagation, enumeration and verification stay in Foundry. */
+#include "wang/solver.h"
+#include "dynamic.h"
+#include
+#include
+
+EM_JS(int, publish_solution, (const uint32_t *domains, const uint64_t *metrics), {
+ return Module['onSolution'](domains, metrics);
+});
+
+EM_ASYNC_JS(int, wait_for_browser, (int action), {
+ return await Module['onWait'](action);
+});
+
+static bool visit(const uint32_t *domains, size_t count,
+ const WangSolverMetrics *metrics, void *unused)
+{
+ (void)count;
+ (void)unused;
+ const uint64_t counters[3] = {
+ metrics->dfs_nodes, metrics->backtracks, metrics->domain_reductions
+ };
+ const int action = publish_solution(domains, counters);
+ return action == 0 || wait_for_browser(action) != 0;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int foundry_enumerate(int32_t width, int32_t height, const uint8_t *active,
+ const uint8_t *boundary, const uint8_t *edges,
+ size_t tile_count, const uint32_t *initial, int reference)
+{
+ if (width <= 0 || height <= 0 || (uint64_t)width * (uint64_t)height > 16384 ||
+ !active || !boundary || !edges || !initial ||
+ tile_count == 0 || tile_count > WANG_RUNTIME_MAX_TILES) return -1;
+ Region region = {0};
+ if (!region_init(®ion, width, height)) return -1;
+ WangTile tiles[WANG_RUNTIME_MAX_TILES] = {0};
+ for (size_t t = 0; t < tile_count; ++t) {
+ tiles[t].id = (TileId)t;
+ memcpy(tiles[t].edge, edges + t * DIR_COUNT, DIR_COUNT);
+ }
+ for (size_t i = 0; i < region.cell_count; ++i) {
+ region.cells[i].active = active[i] != 0;
+ memcpy(region.cells[i].boundary, boundary + i * DIR_COUNT, DIR_COUNT);
+ }
+ const WangSolverOptions options = {
+ .flags = WANG_SOLVE_COLLECT_METRICS,
+ .initial_domains = initial,
+ .initial_domain_count = region.cell_count
+ };
+ const int result = reference
+ ? wang_enumerate_serial_tileset(®ion, tiles, tile_count, &options, visit, NULL, NULL)
+ : wang_enumerate_optimized_tileset(®ion, tiles, tile_count, &options, visit, NULL, NULL);
+ region_destroy(®ion);
+ return result;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int foundry_enumerate_graph(int32_t count, int32_t sides, const int32_t *neighbors,
+ const uint8_t *neighbor_sides, const uint8_t *boundary, const uint8_t *edges,
+ size_t tile_count, const uint32_t *initial, int reference, int wide_colors)
+{
+ if (count <= 0 || sides <= 0 || !initial) return -1;
+ const WangGraph graph = {(size_t)count, (size_t)sides, neighbors, neighbor_sides, boundary, edges};
+ if (tile_count > 32 || count > 4096 || wide_colors) {
+ const FoundryWebGraph web = {(size_t)count, (size_t)sides, neighbors,
+ neighbor_sides, boundary, edges, wide_colors != 0};
+ return foundry_dynamic_graph(&web, tile_count, initial, visit, NULL);
+ }
+ const WangSolverOptions options = {
+ .flags = WANG_SOLVE_COLLECT_METRICS,
+ .initial_domains = initial, .initial_domain_count = (size_t)count
+ };
+ return wang_enumerate_graph(&graph, tile_count, &options, reference != 0, visit, NULL, NULL);
+}
diff --git a/packages/solver-web/client.mjs b/packages/solver-web/client.mjs
new file mode 100644
index 0000000..7c84aeb
--- /dev/null
+++ b/packages/solver-web/client.mjs
@@ -0,0 +1,107 @@
+import {prepare, limits, geometries} from './prepare.mjs';
+
+const terminal = new Set(['complete', 'cancelled', 'timeout', 'expired', 'error']);
+let manifestPromise;
+export async function capabilities() {
+ manifestPromise ??= fetch(new URL('./manifest.json', import.meta.url)).then(async response => {
+ if (!response.ok) throw Error('Pacchetto solver non disponibile.');
+ const manifest = await response.json();
+ if (manifest.api_version !== 1) throw Error('Versione del pacchetto solver non compatibile.');
+ return manifest;
+ }).catch(error => { manifestPromise = null; throw error; });
+ return {solver: 'optimized', solvers: ['optimized', 'reference'], runtime: 'wasm', geometries,
+ enumeration: true, duplicate_tiles: 'collapsed', ...limits,
+ engine: await manifestPromise};
+}
+
+export class Solver {
+ constructor() { this.jobs = new Map(); }
+
+ async start(payload) {
+ const copy = structuredClone(payload);
+ const {timeout, idleTimeout, solver} = prepare(copy);
+ this.reap();
+ if ([...this.jobs.values()].some(job => !terminal.has(job.result.status))) {
+ throw Error('È già presente una ricerca. Premi Stop prima di avviarne un’altra.');
+ }
+ // Reserve the slot before awaiting the manifest to prevent concurrent starts.
+ const id = crypto.randomUUID();
+ const job = {worker: null, timer: null, generation: 0, timeout, idleTimeout, finished: null,
+ result: {status: 'running', solver, runtime: 'wasm', count: '0', exact: false, generation: 0}};
+ this.jobs.set(id, job);
+ this.arm(job, timeout * 1000, 'timeout');
+ try {
+ const caps = await capabilities();
+ if (terminal.has(job.result.status)) return {id, ...job.result};
+ job.result.engine = caps.engine;
+ const worker = new Worker(new URL('./worker.mjs', import.meta.url), {type: 'module'});
+ job.worker = worker;
+ worker.onmessage = ({data}) => {
+ if (terminal.has(job.result.status) || data.generation != null && data.generation !== job.generation) return;
+ if (data.status === 'error') { this.finish(job, 'error', data.message); return; }
+ job.result = {...job.result, ...data};
+ if (data.status === 'paused') this.arm(job, job.idleTimeout * 1000, 'expired');
+ else if (terminal.has(data.status)) this.finish(job, data.status);
+ // Progress does not reset the active deadline; resume() starts a segment.
+ };
+ worker.onerror = event => { event.preventDefault(); this.finish(job, 'error', event.message || 'Errore del Worker.'); };
+ worker.onmessageerror = () => this.finish(job, 'error', 'Risposta del Worker non leggibile.');
+ worker.postMessage({type: 'start', payload: copy});
+ return {id, ...job.result};
+ } catch (error) {
+ this.finish(job, 'error', error.message);
+ throw error;
+ }
+ }
+
+ arm(job, milliseconds, outcome) {
+ clearTimeout(job.timer);
+ if (milliseconds === 0) { job.timer = null; return; }
+ job.timer = setTimeout(() => this.finish(job, outcome), milliseconds);
+ }
+
+ finish(job, status, message) {
+ if (job.finished !== null) return;
+ clearTimeout(job.timer);
+ job.worker?.terminate();
+ job.worker = null;
+ job.finished = Date.now();
+ job.result = {...job.result, status, exact: status === 'complete' && job.result.exact === true};
+ if (message) job.result.message = message;
+ }
+
+ get(id) {
+ const job = this.jobs.get(id);
+ if (!job) throw Error('Ricerca non trovata.');
+ return structuredClone(job.result);
+ }
+
+ cancel(id) {
+ const job = this.jobs.get(id);
+ if (!job) throw Error('Ricerca non trovata.');
+ if (!terminal.has(job.result.status)) this.finish(job, 'cancelled');
+ return this.get(id);
+ }
+
+ resume(id, action) {
+ const job = this.jobs.get(id);
+ if (!job || job.result.status !== 'paused' || !['next', 'count'].includes(action)) {
+ throw Error('La ricerca non è in attesa di una soluzione successiva.');
+ }
+ job.generation++;
+ job.result = {...job.result, status: 'running', exact: false, generation: job.generation};
+ this.arm(job, job.timeout * 1000, 'timeout');
+ job.worker.postMessage({type: 'resume', action, generation: job.generation});
+ return this.get(id);
+ }
+
+ reap() {
+ for (const [id, job] of this.jobs) {
+ if (job.finished !== null && Date.now() - job.finished > 600000) this.jobs.delete(id);
+ }
+ }
+
+ close() {
+ for (const job of this.jobs.values()) if (!terminal.has(job.result.status)) this.finish(job, 'cancelled');
+ }
+}
diff --git a/packages/solver-web/dynamic.c b/packages/solver-web/dynamic.c
new file mode 100644
index 0000000..6b18895
--- /dev/null
+++ b/packages/solver-web/dynamic.c
@@ -0,0 +1,278 @@
+#include "dynamic.h"
+#include
+#include
+
+typedef struct {
+ size_t index;
+ uint32_t old;
+} Change;
+typedef struct {
+ size_t cell, next, mark;
+} Frame;
+typedef struct {
+ const FoundryWebGraph *g;
+ size_t tiles, words, used, capacity, color_count;
+ uint32_t *color_stamps, epoch;
+ uint32_t *domains;
+ Change *trail;
+ WangSolverMetrics metrics;
+} Search;
+
+static uint32_t edge(const FoundryWebGraph *g, size_t i)
+{
+ return g->wide_colors ? ((const uint32_t *)g->edges)[i] : ((const uint8_t *)g->edges)[i];
+}
+static uint32_t boundary(const FoundryWebGraph *g, size_t i)
+{
+ uint32_t value =
+ g->wide_colors ? ((const uint32_t *)g->boundary)[i] : ((const uint8_t *)g->boundary)[i];
+ return !g->wide_colors && value == COLOR_NONE ? UINT32_MAX : value;
+}
+static void *array(size_t n, size_t size)
+{
+ return n && n <= SIZE_MAX / size ? calloc(n, size) : NULL;
+}
+static bool has(const Search *s, size_t c, size_t t)
+{
+ return (s->domains[c * s->words + t / 32] & (UINT32_C(1) << (t % 32))) != 0;
+}
+static bool change(Search *s, size_t i, uint32_t value)
+{
+ if (s->domains[i] == value)
+ return true;
+ if (s->used == s->capacity) {
+ size_t cap = s->capacity ? s->capacity * 2 : 256;
+ if (cap < s->capacity || cap > SIZE_MAX / sizeof(Change))
+ return false;
+ Change *p = realloc(s->trail, cap * sizeof(Change));
+ if (!p)
+ return false;
+ s->trail = p;
+ s->capacity = cap;
+ }
+ s->trail[s->used++] = (Change){i, s->domains[i]};
+ s->domains[i] = value;
+ ++s->metrics.domain_reductions;
+ return true;
+}
+static void undo(Search *s, size_t mark)
+{
+ while (s->used > mark) {
+ Change c = s->trail[--s->used];
+ s->domains[c.index] = c.old;
+ }
+}
+/* Arc consistency: an edge value is supported iff it occurs on the incident
+ * neighbor side. Revisit all arcs until no word changes. */
+static int propagate(Search *s)
+{
+ const FoundryWebGraph *g = s->g;
+ size_t before;
+ do {
+ before = s->used;
+ for (size_t c = 0; c < g->cell_count; ++c) {
+ for (size_t d = 0; d < g->side_count; ++d) {
+ size_t a = c * g->side_count + d;
+ int32_t n = g->neighbors[a];
+ if (n < 0)
+ continue;
+ if (++s->epoch == 0) {
+ memset(s->color_stamps, 0, s->color_count * sizeof(uint32_t));
+ s->epoch = 1;
+ }
+ for (size_t t = 0; t < s->tiles; ++t)
+ if (has(s, (size_t)n, t))
+ s->color_stamps[edge(g, t * g->side_count + g->neighbor_sides[a])] =
+ s->epoch;
+ for (size_t w = 0; w < s->words; ++w) {
+ size_t i = c * s->words + w;
+ uint32_t bits = s->domains[i], kept = bits;
+ while (bits) {
+ unsigned b = (unsigned)__builtin_ctz(bits);
+ size_t t = w * 32 + b;
+ if (s->color_stamps[edge(g, t * g->side_count + d)] != s->epoch)
+ kept &= ~(UINT32_C(1) << b);
+ bits &= bits - 1;
+ }
+ if (!change(s, i, kept))
+ return -1;
+ }
+ }
+ uint32_t any = 0;
+ for (size_t w = 0; w < s->words; ++w)
+ any |= s->domains[c * s->words + w];
+ if (!any)
+ return 0;
+ }
+ } while (before != s->used);
+ return 1;
+}
+static size_t choose(Search *s)
+{
+ size_t best = SIZE_MAX, minimum = SIZE_MAX;
+ for (size_t c = 0; c < s->g->cell_count; ++c) {
+ size_t count = 0;
+ for (size_t w = 0; w < s->words; ++w)
+ count += (unsigned)__builtin_popcount(s->domains[c * s->words + w]);
+ if (count > 1 && count < minimum) {
+ best = c;
+ minimum = count;
+ }
+ }
+ return best;
+}
+static size_t singleton(const Search *s, size_t c)
+{
+ size_t tile = SIZE_MAX;
+ for (size_t w = 0; w < s->words; ++w) {
+ uint32_t bits = s->domains[c * s->words + w];
+ if (!bits)
+ continue;
+ if (tile != SIZE_MAX || (bits & (bits - 1)))
+ return SIZE_MAX;
+ tile = w * 32 + (unsigned)__builtin_ctz(bits);
+ }
+ return tile;
+}
+static bool verify(const Search *s, const uint32_t *initial)
+{
+ const FoundryWebGraph *g = s->g;
+ for (size_t c = 0; c < g->cell_count; ++c) {
+ size_t t = singleton(s, c);
+ if (t >= s->tiles || !(initial[c * s->words + t / 32] & (UINT32_C(1) << (t % 32))))
+ return false;
+ for (size_t d = 0; d < g->side_count; ++d) {
+ size_t a = c * g->side_count + d;
+ uint32_t color = edge(g, t * g->side_count + d);
+ if (boundary(g, a) != UINT32_MAX && boundary(g, a) != color)
+ return false;
+ if (g->neighbors[a] >= 0) {
+ size_t u = singleton(s, (size_t)g->neighbors[a]);
+ if (u >= s->tiles || color != edge(g, u * g->side_count + g->neighbor_sides[a]))
+ return false;
+ }
+ }
+ }
+ return true;
+}
+int foundry_dynamic_graph(const FoundryWebGraph *g, size_t tiles, const uint32_t *initial,
+ WangSolutionVisitor visit, void *userdata)
+{
+ if (!g || !initial || !visit || !tiles || tiles > SIZE_MAX - 31 || !g->cell_count ||
+ g->cell_count > FOUNDRY_WEB_MAX_CELLS || !g->side_count || g->side_count > 6 ||
+ !g->neighbors || !g->neighbor_sides || !g->boundary || !g->edges ||
+ tiles > SIZE_MAX / g->side_count)
+ return -1;
+ if (tiles * g->side_count > SIZE_MAX / (g->wide_colors ? sizeof(uint32_t) : 1))
+ return -1;
+ size_t color_count = 1;
+ for (size_t i = 0; i < tiles * g->side_count; ++i) {
+ uint32_t color = edge(g, i);
+ if (color >= FOUNDRY_WEB_MAX_COLORS)
+ return -1;
+ if ((size_t)color + 1 > color_count)
+ color_count = (size_t)color + 1;
+ }
+ for (size_t c = 0; c < g->cell_count; ++c)
+ for (size_t d = 0; d < g->side_count; ++d) {
+ size_t a = c * g->side_count + d;
+ int32_t n = g->neighbors[a];
+ if (boundary(g, a) != UINT32_MAX && boundary(g, a) >= FOUNDRY_WEB_MAX_COLORS)
+ return -1;
+ if (n == -1)
+ continue;
+ if (n < 0 || (size_t)n >= g->cell_count || (size_t)n == c ||
+ g->neighbor_sides[a] >= g->side_count)
+ return -1;
+ size_t back = (size_t)n * g->side_count + g->neighbor_sides[a];
+ if (g->neighbors[back] != (int32_t)c || g->neighbor_sides[back] != d)
+ return -1;
+ }
+ Search s = {.g = g, .tiles = tiles, .words = (tiles + 31) / 32, .color_count = color_count};
+ if (s.words > SIZE_MAX / g->cell_count)
+ return -1;
+ size_t length = s.words * g->cell_count;
+ s.domains = array(length, sizeof(uint32_t));
+ s.color_stamps = array(color_count, sizeof(uint32_t));
+ Frame *stack = array(g->cell_count, sizeof(Frame));
+ int result = -1;
+ if (!s.domains || !stack || !s.color_stamps)
+ goto done;
+ memcpy(s.domains, initial, length * sizeof(uint32_t));
+ for (size_t c = 0; c < g->cell_count; ++c) {
+ if (tiles % 32 && (s.domains[(c + 1) * s.words - 1] >> (tiles % 32)))
+ goto done;
+ for (size_t t = 0; t < tiles; ++t)
+ if (has(&s, c, t)) {
+ bool keep = true;
+ for (size_t d = 0; d < g->side_count; ++d) {
+ uint32_t color = boundary(g, c * g->side_count + d);
+ if (color != UINT32_MAX && color != edge(g, t * g->side_count + d))
+ keep = false;
+ }
+ /* Collapse only allowed duplicate tuples, retaining the first ID. */
+ for (size_t u = 0; keep && u < t; ++u)
+ if (has(&s, c, u) && !memcmp((const uint8_t *)g->edges +
+ u * g->side_count * (g->wide_colors ? 4 : 1),
+ (const uint8_t *)g->edges +
+ t * g->side_count * (g->wide_colors ? 4 : 1),
+ g->side_count * (g->wide_colors ? 4 : 1)))
+ keep = false;
+ if (!keep)
+ s.domains[c * s.words + t / 32] &= ~(UINT32_C(1) << (t % 32));
+ }
+ }
+ size_t depth = 0;
+ int consistent = propagate(&s);
+ if (consistent < 0)
+ goto done;
+ s.used = 0; /* Root pruning is permanent. */
+ for (;;) {
+ if (consistent) {
+ ++s.metrics.dfs_nodes;
+ size_t cell = choose(&s);
+ if (cell == SIZE_MAX) {
+ if (!verify(&s, initial))
+ goto done;
+ if (!visit(s.domains, g->cell_count, &s.metrics, userdata)) {
+ result = 1;
+ goto done;
+ }
+ } else {
+ stack[depth++] = (Frame){cell, 0, s.used};
+ }
+ }
+ bool branched = false;
+ while (depth) {
+ Frame *f = &stack[depth - 1];
+ undo(&s, f->mark);
+ size_t t = f->next;
+ while (t < tiles && !has(&s, f->cell, t))
+ ++t;
+ if (t == tiles) {
+ --depth;
+ ++s.metrics.backtracks;
+ continue;
+ }
+ f->next = t + 1;
+ for (size_t w = 0; w < s.words; ++w)
+ if (!change(&s, f->cell * s.words + w, w == t / 32 ? UINT32_C(1) << (t % 32) : 0))
+ goto done;
+ consistent = propagate(&s);
+ if (consistent < 0)
+ goto done;
+ branched = true;
+ break;
+ }
+ if (!branched) {
+ result = 0;
+ break;
+ }
+ }
+done:
+ free(stack);
+ free(s.domains);
+ free(s.trail);
+ free(s.color_stamps);
+ return result;
+}
diff --git a/packages/solver-web/dynamic.h b/packages/solver-web/dynamic.h
new file mode 100644
index 0000000..adc374a
--- /dev/null
+++ b/packages/solver-web/dynamic.h
@@ -0,0 +1,19 @@
+/* Private browser engine. Domains are cell-major arrays of ceil(tiles/32) words.
+ * Callback snapshots have the same layout. Native solver APIs are untouched. */
+#ifndef FOUNDRY_WEB_DYNAMIC_H
+#define FOUNDRY_WEB_DYNAMIC_H
+#include "wang/solver.h"
+#define FOUNDRY_WEB_MAX_CELLS 65536u
+#define FOUNDRY_WEB_MAX_COLORS 65536u
+/* Byte colors retain ABI 2 compatibility; wide colors use uint32_t with
+ * UINT32_MAX for unconstrained boundaries. Native WangGraph is unchanged. */
+typedef struct {
+ size_t cell_count, side_count;
+ const int32_t *neighbors;
+ const uint8_t *neighbor_sides;
+ const void *boundary, *edges;
+ bool wide_colors;
+} FoundryWebGraph;
+int foundry_dynamic_graph(const FoundryWebGraph *g, size_t tiles, const uint32_t *initial,
+ WangSolutionVisitor visit, void *userdata);
+#endif
diff --git a/packages/solver-web/package.json b/packages/solver-web/package.json
new file mode 100644
index 0000000..d1c030a
--- /dev/null
+++ b/packages/solver-web/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@tiling-foundry/solver-web",
+ "version": "0.3.0",
+ "type": "module",
+ "license": "MIT",
+ "exports": "./client.mjs",
+ "description": "Tiling Foundry reference and optimized square solvers in a dedicated browser Worker"
+}
diff --git a/packages/solver-web/prepare.mjs b/packages/solver-web/prepare.mjs
new file mode 100644
index 0000000..445140e
--- /dev/null
+++ b/packages/solver-web/prepare.mjs
@@ -0,0 +1,131 @@
+/* Input validation and independent witness checking. No search implementation. */
+export const limits = Object.freeze({
+ max_tiles: null, max_used_colors: 65536, max_cells: 65536, max_area: null,
+ max_palette: 65536, default_timeout_seconds: 600, max_timeout_seconds: 2147483,
+ default_idle_timeout_seconds: 0, max_memory_bytes: 2147483648,
+});
+export const geometries = ['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'];
+const offsets = [[0, -1], [1, 0], [0, 1], [-1, 0]];
+
+// Lattice coordinates are an input adapter only. The C core receives cell/side arcs.
+export function adjacent(geometry, id) {
+ const [x, y, half] = id.split(',').map(Number);
+ if (geometry === 'square') return offsets.map(([dx,dy],d) => [`${x+dx},${y+dy}`, (d+2)%4]);
+ if (geometry.startsWith('tri')) return [
+ [`${x},${y+(half ? 1 : -1)},${1-half}`,0],
+ [`${x},${y},${1-half}`,1],
+ [`${x+(half ? 1 : -1)},${y},${1-half}`,2],
+ ];
+ const steps = geometry === 'hex-flat'
+ ? [[1,0],[0,1],[-1,1],[-1,0],[0,-1],[1,-1]]
+ : [[1,-1],[1,0],[0,1],[-1,1],[-1,0],[0,-1]];
+ return steps.map(([dx,dy],d) => [`${x+dx},${y+dy}`, (d+3)%6]);
+}
+
+export function prepare(data) {
+ if (!data || !geometries.includes(data.geometry)) throw Error('Geometria non supportata.');
+ let graph = data.geometry !== 'square' || data.tiles?.length > 32;
+ const triangle = data.geometry.startsWith('tri');
+ const sides = triangle ? 3 : data.geometry.startsWith('hex') ? 6 : 4;
+ const {palette, tiles, cells} = data;
+ if (!Array.isArray(palette) || !palette.length || palette.length > limits.max_palette ||
+ !palette.every(c => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c))) {
+ throw Error('Palette non valida.');
+ }
+ function edges(value, nullable = false) {
+ if (!Array.isArray(value) || value.length !== sides || !value.every(v =>
+ (nullable && v === null) || (Number.isInteger(v) && v >= 0 && v < palette.length))) {
+ throw Error('Una tessera o un vincolo contiene lati non validi.');
+ }
+ return value;
+ }
+ if (!Array.isArray(tiles) || !tiles.length) {
+ throw Error('Il solver richiede almeno una tessera.');
+ }
+ tiles.forEach(t => edges(t));
+ if (!Array.isArray(cells) || !cells.length || cells.length > limits.max_cells) {
+ throw Error('La regione deve contenere da 1 a 65536 celle.');
+ }
+ const timeout = data.timeout_seconds ?? limits.default_timeout_seconds;
+ const idleTimeout = data.idle_timeout_seconds ?? limits.default_idle_timeout_seconds;
+ if ([timeout, idleTimeout].some(t => typeof t !== 'number' || !Number.isFinite(t) || t < 0 || t > limits.max_timeout_seconds)) {
+ throw Error('Tempo limite non valido (0 = senza scadenza; massimo 2147483 secondi).');
+ }
+ const mode = data.enumeration_mode ?? 'step';
+ if (!['step', 'count'].includes(mode)) throw Error('Modalità di enumerazione non valida.');
+ const solver = data.solver ?? 'optimized';
+ if (!['optimized', 'reference'].includes(solver)) throw Error('Solver non supportato.');
+ const records = new Map();
+ const used = new Set(tiles.flat());
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
+ for (const cell of cells) {
+ if (!cell || typeof cell.id !== 'string' || !(triangle ? /^-?\d{1,7},-?\d{1,7},[01]$/ : /^-?\d{1,7},-?\d{1,7}$/).test(cell.id)) {
+ throw Error('Coordinate non valide.');
+ }
+ const [x, y] = cell.id.split(',').map(Number);
+ const key = triangle ? `${x},${y},${cell.id.split(',')[2]}` : `${x},${y}`;
+ if (Math.abs(x) > 1000000 || Math.abs(y) > 1000000 || cell.id !== key || records.has(key)) {
+ throw Error('Coordinate duplicate o fuori intervallo.');
+ }
+ edges(cell.required, true);
+ if (cell.fixed != null) edges(cell.fixed);
+ cell.required.forEach(v => { if (v !== null) used.add(v); });
+ cell.fixed?.forEach(v => used.add(v));
+ records.set(key, {...cell, x, y});
+ x0 = Math.min(x0, x); x1 = Math.max(x1, x);
+ y0 = Math.min(y0, y); y1 = Math.max(y1, y);
+ }
+ const width = x1 - x0 + 1, height = y1 - y0 + 1;
+ const wide = used.size > 16;
+ graph ||= wide || cells.length > 4096 || width * height > 16384;
+ const area = graph ? cells.length : width * height;
+ if (used.size > limits.max_used_colors) throw Error('Il solver supporta al massimo 65536 colori usati.');
+ // Colors are symbolic palette indices. Equal RGB values remain distinct.
+ const remap = new Map([...used].sort((a, b) => a - b).map((v, i) => [v, i]));
+ const ColorArray = wide ? Uint32Array : Uint8Array;
+ const active = new Uint8Array(area), boundary = new ColorArray(area * sides).fill(wide ? 0xffffffff : 255);
+ const words = Math.ceil(tiles.length / 32);
+ const domains = new Uint32Array(area * words), indices = new Map();
+ const neighbors = new Int32Array(area * sides).fill(-1);
+ const neighborSides = new Uint8Array(area * sides);
+ for (const cell of records.values()) indices.set(cell.id, graph ? indices.size : (cell.y-y0)*width+cell.x-x0);
+ for (const cell of records.values()) {
+ const index = indices.get(cell.id);
+ indices.set(cell.id, index);
+ active[index] = 1;
+ tiles.forEach((tile, t) => {
+ if ((!cell.fixed || tile.every((v, d) => v === cell.fixed[d])) &&
+ cell.required.every((v, d) => v === null || tile[d] === v)) {
+ domains[index * words + Math.floor(t / 32)] |= 1 << (t % 32);
+ }
+ });
+ adjacent(data.geometry, cell.id).forEach(([id, side], d) => {
+ neighbors[index * sides + d] = indices.get(id) ?? -1;
+ neighborSides[index * sides + d] = side;
+ if (cell.required[d] !== null && (graph || !records.has(id))) {
+ boundary[index * sides + d] = remap.get(cell.required[d]);
+ }
+ });
+ }
+ return {geometry: data.geometry, words, wide, graph, sides, neighbors, neighborSides, width, height, active, boundary, domains, indices, records, tiles,
+ edges: new ColorArray(tiles.flat().map(v => remap.get(v))), timeout, idleTimeout, mode, solver};
+}
+
+export function checkAssignments(prepared, assignments) {
+ if (Object.keys(assignments).length !== prepared.records.size) throw Error('Soluzione incompleta.');
+ for (const cell of prepared.records.values()) {
+ const id = assignments[cell.id], tile = prepared.tiles[id];
+ if (!Number.isInteger(id) || !tile ||
+ (cell.fixed && tile.some((v, d) => v !== cell.fixed[d]))) {
+ throw Error('Soluzione non valida o tessera fissata modificata.');
+ }
+ adjacent(prepared.geometry, cell.id).forEach(([id, side], d) => {
+ const other = prepared.records.get(id);
+ if ((cell.required[d] !== null && tile[d] !== cell.required[d]) ||
+ (other && tile[d] !== prepared.tiles[assignments[other.id]]?.[side])) {
+ throw Error('La soluzione contiene lati incompatibili.');
+ }
+ });
+ }
+ return assignments;
+}
diff --git a/packages/solver-web/worker.mjs b/packages/solver-web/worker.mjs
new file mode 100644
index 0000000..75e16f4
--- /dev/null
+++ b/packages/solver-web/worker.mjs
@@ -0,0 +1,93 @@
+import createModule from './foundry.mjs';
+import {prepare, checkAssignments} from './prepare.mjs';
+
+let resume = null, started = false;
+self.onmessage = ({data}) => {
+ if (data.type === 'start' && !started) {
+ started = true;
+ run(data.payload).catch(error => self.postMessage({status: 'error', exact: false, message: error.message}));
+ } else if (data.type === 'resume' && resume && ['next', 'count'].includes(data.action)) {
+ const accept = resume;
+ resume = null;
+ accept(data);
+ }
+};
+
+async function run(payload) {
+ const prepared = prepare(payload);
+ const module = await createModule();
+ let count = 0n, generation = 0, mode = prepared.mode, assignments = null;
+ let metrics = {}, lastEmit = 0, pausedAt = 0, pausedTime = 0;
+ const began = performance.now();
+ const publish = (status, exact = false) => self.postMessage({
+ status, exact, count: count.toString(), assignments, metrics, generation,
+ elapsed_ms: Math.round((performance.now() - began - pausedTime) * 100) / 100,
+ });
+ const snapshot = pointer => {
+ const result = {};
+ for (const [id, index] of prepared.indices) {
+ let tile = -1;
+ for (let word = 0; word < prepared.words; word++) {
+ const mask = module.HEAPU32[(pointer >>> 2) + index * prepared.words + word];
+ if (!mask) continue;
+ if (tile !== -1 || (mask & (mask - 1)) !== 0) throw Error('Dominio non singleton nella soluzione.');
+ tile = word * 32 + 31 - Math.clz32(mask);
+ }
+ if (tile < 0 || tile >= prepared.tiles.length) throw Error('Indice tessera non valido.');
+ result[id] = tile;
+ }
+ return checkAssignments(prepared, result);
+ };
+ module.onSolution = (domains, counters) => {
+ count++;
+ const now = performance.now();
+ if (mode !== 'step' && count !== 1n && now - lastEmit < 150) return 0;
+ // Copy only published snapshots; native Foundry verifies every witness.
+ if (mode === 'step' || assignments === null) assignments = snapshot(domains);
+ const values = new DataView(module.HEAPU8.buffer);
+ metrics = Object.fromEntries(['nodes', 'backtracks', 'reductions'].map((key, i) =>
+ [key, values.getBigUint64(counters + i * 8, true).toString()]));
+ lastEmit = now;
+ if (mode === 'step') {
+ pausedAt = now;
+ publish('paused');
+ return 1;
+ }
+ publish('running');
+ return 2;
+ };
+ module.onWait = async action => {
+ if (action === 1) {
+ const command = await new Promise(resolve => { resume = resolve; });
+ generation = command.generation;
+ mode = command.action === 'count' ? 'count' : 'step';
+ pausedTime += performance.now() - pausedAt;
+ publish('running');
+ } else {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ return 1;
+ };
+ const allocations = [];
+ const allocate = array => {
+ const pointer = module._malloc(array.byteLength);
+ if (!pointer) throw Error('Memoria insufficiente per la ricerca.');
+ allocations.push(pointer);
+ module.HEAPU8.set(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), pointer);
+ return pointer;
+ };
+ publish('running');
+ try {
+ const args = prepared.graph ? [prepared.records.size, prepared.sides, allocate(prepared.neighbors),
+ allocate(prepared.neighborSides), allocate(prepared.boundary), allocate(prepared.edges),
+ prepared.tiles.length, allocate(prepared.domains), Number(prepared.solver === 'reference'), Number(prepared.wide)] : [prepared.width, prepared.height, allocate(prepared.active),
+ allocate(prepared.boundary), allocate(prepared.edges), prepared.tiles.length,
+ allocate(prepared.domains), Number(prepared.solver === 'reference')];
+ const status = await module.ccall(prepared.graph ? 'foundry_enumerate_graph' : 'foundry_enumerate', 'number',
+ Array(args.length).fill('number'), args, {async: true});
+ if (status !== 0) throw Error('Il motore ha interrotto la ricerca senza esaurirla.');
+ publish('complete', true);
+ } finally {
+ allocations.forEach(pointer => module._free(pointer));
+ }
+}
diff --git a/src/solver/solver_serial.c b/src/solver/solver_serial.c
index d125a1f..a76f002 100644
--- a/src/solver/solver_serial.c
+++ b/src/solver/solver_serial.c
@@ -21,12 +21,12 @@
enum {
MRV_MIN_DOMAIN_SIZE = 2,
- MRV_BUCKET_COUNT = TILE_COUNT - MRV_MIN_DOMAIN_SIZE + 1
+ MRV_BUCKET_COUNT = WANG_RUNTIME_MAX_TILES - MRV_MIN_DOMAIN_SIZE + 1
};
typedef struct {
- uint32_t edge_mask[DIR_COUNT][COLOR_COUNT];
- uint32_t compat[DIR_COUNT][TILE_COUNT];
+ uint32_t edge_mask[WANG_GRAPH_MAX_SIDES][COLOR_COUNT];
+ uint32_t compat[WANG_GRAPH_MAX_SIDES * WANG_GRAPH_MAX_SIDES][WANG_RUNTIME_MAX_TILES];
} SolverTables;
typedef struct {
@@ -68,10 +68,22 @@ typedef struct {
size_t allocated_bytes;
} SearchStack;
+typedef struct {
+ WangSolutionVisitor visitor;
+ void *userdata;
+ WangSolverMetrics *metrics;
+} Enumeration;
+
typedef struct {
const Region *region;
+ const WangGraph *graph;
+ Enumeration *enumeration;
SolverTables tables;
- ByteSupportTables *byte_support;
+ uint32_t *byte_support;
+ size_t domain_byte_count;
+ const WangTile *tileset;
+ size_t tileset_count;
+ uint32_t all_tiles;
uint32_t *domains;
uint8_t *neighbor_mask;
@@ -206,34 +218,36 @@ static bool result_is_destroyed(const WangSolveResult *result)
metrics_are_zero(&result->metrics);
}
-static void build_solver_tables(SolverTables *tables)
+static void build_solver_tables(SolverTables *tables,
+ const WangTile *tileset, size_t tile_count)
{
memset(tables, 0, sizeof(*tables));
- for (TileId tile = 0; tile < TILE_COUNT; ++tile) {
+ for (TileId tile = 0; tile < tile_count; ++tile) {
for (Dir dir = N; dir < DIR_COUNT; ++dir) {
- const ColorId color = TILESET[tile].edge[dir];
+ const ColorId color = tileset[tile].edge[dir];
tables->edge_mask[dir][color] |= UINT32_C(1) << tile;
}
}
- for (TileId tile = 0; tile < TILE_COUNT; ++tile) {
+ for (TileId tile = 0; tile < tile_count; ++tile) {
for (Dir dir = N; dir < DIR_COUNT; ++dir) {
- const ColorId color = TILESET[tile].edge[dir];
+ const ColorId color = tileset[tile].edge[dir];
tables->compat[dir][tile] =
tables->edge_mask[opposite(dir)][color];
}
}
}
-static bool solver_tables_are_valid(const SolverTables *tables)
+static bool solver_tables_are_valid(const SolverTables *tables,
+ const WangTile *tileset, size_t tile_count)
{
- for (TileId a = 0; a < TILE_COUNT; ++a) {
- for (TileId b = 0; b < TILE_COUNT; ++b) {
+ for (TileId a = 0; a < tile_count; ++a) {
+ for (TileId b = 0; b < tile_count; ++b) {
for (Dir dir = N; dir < DIR_COUNT; ++dir) {
const bool cached =
(tables->compat[dir][a] & (UINT32_C(1) << b)) != 0;
- if (cached != wang_tiles_match(&TILESET[a], dir, &TILESET[b])) {
+ if (cached != wang_tiles_match(&tileset[a], dir, &tileset[b])) {
return false;
}
}
@@ -402,7 +416,7 @@ static size_t first_set_bit_u32(uint32_t value)
static bool mrv_size_is_indexed(unsigned size)
{
- return size >= MRV_MIN_DOMAIN_SIZE && size <= TILE_COUNT;
+ return size >= MRV_MIN_DOMAIN_SIZE && size <= WANG_RUNTIME_MAX_TILES;
}
static size_t mrv_bucket_for_size(unsigned size)
@@ -461,7 +475,8 @@ static bool mrv_index_move(
new_size = old_size - removed_count;
} else if (removed == 0) {
const unsigned added_count = domain_popcount(added);
- if (old_size > TILE_COUNT - added_count) {
+ if (added_count > state->tileset_count ||
+ old_size > state->tileset_count - added_count) {
return false;
}
new_size = old_size + added_count;
@@ -531,7 +546,7 @@ static bool allocate_mrv_index(SolverState *state)
size_t bucket_bytes;
size_t size_bytes;
if (!checked_mul_size(
- MRV_BUCKET_COUNT,
+ state->tileset_count > 1 ? state->tileset_count - 1 : 1,
state->mrv_word_count,
&bucket_word_count
) ||
@@ -809,6 +824,7 @@ static size_t neighbor_index(
Dir dir
)
{
+ if (state->graph) return (size_t)state->graph->neighbors[cell_index * state->graph->side_count + dir];
const size_t width = (size_t)state->region->width;
switch (dir) {
case N:
@@ -827,16 +843,17 @@ static size_t neighbor_index(
static uint32_t supported_neighbor_domain(
SolverState *state,
- Dir dir,
+ size_t dir,
uint32_t domain
)
{
uint32_t supported = 0;
if (state->byte_support != NULL) {
- for (size_t byte = 0; byte < WANG_DOMAIN_BYTE_COUNT; ++byte) {
+ for (size_t byte = 0; byte < state->domain_byte_count; ++byte) {
const uint8_t value = (uint8_t)domain;
if (value != 0) {
- supported |= state->byte_support->support[dir][byte][value];
+ supported |= state->byte_support[(dir * state->domain_byte_count + byte) *
+ WANG_SUPPORT_BYTE_VALUE_COUNT + value];
if (state->collect_metrics) {
++state->metrics.support_byte_lookups;
}
@@ -878,7 +895,7 @@ static PropagateStatus propagate_queue(
return PROPAGATE_CONFLICT;
}
- for (Dir dir = N; dir < DIR_COUNT; ++dir) {
+ for (size_t dir = 0; dir < (state->graph ? state->graph->side_count : DIR_COUNT); ++dir) {
if ((state->neighbor_mask[cell_index] &
(uint8_t)(UINT8_C(1) << dir)) == 0) {
continue;
@@ -890,7 +907,8 @@ static PropagateStatus propagate_queue(
const uint32_t supported = supported_neighbor_domain(
state,
- dir,
+ state->graph ? dir * state->graph->side_count +
+ state->graph->neighbor_sides[cell_index * state->graph->side_count + dir] : dir,
domain
);
@@ -1034,7 +1052,7 @@ static size_t select_mrv_cell(SolverState *state)
}
size_t selected = SIZE_MAX;
- unsigned best_size = TILE_COUNT + 1u;
+ unsigned best_size = (unsigned)state->tileset_count + 1u;
for (size_t i = 0; i < state->cell_count; ++i) {
if (!state->region->cells[i].active) {
@@ -1156,6 +1174,8 @@ static void note_search_stack_capacity(
}
}
+static bool verify_sat_domains(const SolverState *state);
+
static WangSolveStatus search(
SolverState *state,
SearchStackMode stack_mode
@@ -1165,6 +1185,12 @@ static WangSolveStatus search(
if (state->resolved_count == state->active_count) {
state->best_depth = 0;
+ if (state->enumeration != NULL) {
+ if (!verify_sat_domains(state)) return WANG_SOLVE_ERROR;
+ return state->enumeration->visitor(
+ state->domains, state->cell_count, &state->metrics,
+ state->enumeration->userdata) ? WANG_SOLVE_UNSAT : WANG_SOLVE_SAT;
+ }
return WANG_SOLVE_SAT;
}
@@ -1322,6 +1348,16 @@ static WangSolveStatus search(
if (state->resolved_count == state->active_count) {
state->best_depth = branch_depth;
+ if (state->enumeration != NULL) {
+ if (!verify_sat_domains(state)) break;
+ if (state->enumeration->visitor(
+ state->domains, state->cell_count, &state->metrics,
+ state->enumeration->userdata)) {
+ if (!rollback_to(state, mark)) break;
+ if (state->collect_metrics) ++state->metrics.backtracks;
+ continue;
+ }
+ }
status = WANG_SOLVE_SAT;
break;
}
@@ -1450,12 +1486,22 @@ static bool initialize_domains(
++state->active_count;
uint32_t domain = initial_domains != NULL
? initial_domains[index]
- : WANG_DOMAIN_ALL;
- if (state->collect_metrics && domain != WANG_DOMAIN_ALL) {
+ : state->all_tiles;
+ if (state->collect_metrics && domain != state->all_tiles) {
++state->metrics.domain_reductions;
}
- for (Dir dir = N; dir < DIR_COUNT; ++dir) {
+ for (size_t dir = 0; dir < (state->graph ? state->graph->side_count : DIR_COUNT); ++dir) {
+ if (state->graph) {
+ const size_t side = index * state->graph->side_count + dir;
+ if (state->graph->neighbors[side] >= 0) {
+ state->neighbor_mask[index] |= (uint8_t)(1u << dir);
+ state->has_neighbor_arcs = true;
+ }
+ const uint8_t color = state->graph->boundary[side];
+ if (color != COLOR_NONE) domain &= state->tables.edge_mask[dir][color];
+ continue;
+ }
const RegionCell *neighbor = region_cell_const(
state->region,
x + dx[dir],
@@ -1480,8 +1526,30 @@ static bool initialize_domains(
}
}
- /* An isolated cell cannot constrain any other choice. */
- if (state->neighbor_mask[index] == 0 &&
+ if (state->enumeration != NULL) {
+ uint32_t unique = 0;
+ for (size_t t = 0; t < state->tileset_count; ++t) {
+ const uint32_t bit = UINT32_C(1) << t;
+ if ((domain & bit) == 0) continue;
+ bool duplicate = false;
+ for (size_t u = 0; u < t; ++u) {
+ if ((unique & (UINT32_C(1) << u)) != 0 &&
+ (state->graph
+ ? memcmp(state->graph->edges + t * state->graph->side_count,
+ state->graph->edges + u * state->graph->side_count,
+ state->graph->side_count)
+ : memcmp(state->tileset[t].edge, state->tileset[u].edge,
+ sizeof(state->tileset[t].edge))) == 0) {
+ duplicate = true;
+ break;
+ }
+ }
+ if (!duplicate) unique |= bit;
+ }
+ domain = unique;
+ }
+ /* Collapsing isolated choices is valid for SAT, not counting. */
+ if (state->enumeration == NULL && state->neighbor_mask[index] == 0 &&
!domain_is_singleton(domain) && domain != 0) {
domain &= UINT32_C(0) - domain;
if (state->collect_metrics) {
@@ -1503,6 +1571,25 @@ static bool initialize_domains(
static bool verify_sat_domains(const SolverState *state)
{
+ if (state->graph) {
+ const WangGraph *g = state->graph;
+ for (size_t i = 0; i < g->cell_count; ++i) {
+ if (!domain_is_singleton(state->domains[i])) return false;
+ const size_t t = first_set_tile(state->domains[i]);
+ for (size_t d = 0; d < g->side_count; ++d) {
+ const size_t side = i * g->side_count + d;
+ const uint8_t color = g->edges[t * g->side_count + d];
+ if (g->boundary[side] != COLOR_NONE && g->boundary[side] != color) return false;
+ const int32_t n = g->neighbors[side];
+ if (n >= 0) {
+ if (!domain_is_singleton(state->domains[n])) return false;
+ const size_t u = first_set_tile(state->domains[n]);
+ if (color != g->edges[u * g->side_count + g->neighbor_sides[side]]) return false;
+ }
+ }
+ }
+ return true;
+ }
size_t bytes;
if (!checked_mul_size(state->cell_count, sizeof(TileId), &bytes)) {
return false;
@@ -1526,7 +1613,8 @@ static bool verify_sat_domains(const SolverState *state)
}
const bool valid = complete &&
- wang_verify_tiling(state->region, tiles, state->cell_count) ==
+ wang_verify_tiling_with_tileset(state->region, tiles, state->cell_count,
+ state->tileset, state->tileset_count) ==
WANG_VERIFY_VALID;
free(tiles);
return valid;
@@ -1558,7 +1646,8 @@ static bool solver_options_are_valid(const WangSolverOptions *options)
static bool initial_domains_are_valid(
const Region *region,
- const WangSolverOptions *options
+ const WangSolverOptions *options,
+ uint32_t all_tiles
)
{
if (options == NULL) {
@@ -1573,7 +1662,7 @@ static bool initial_domains_are_valid(
for (size_t i = 0; i < region->cell_count; ++i) {
const uint32_t mask = options->initial_domains[i];
- if ((mask & ~WANG_DOMAIN_ALL) != 0 ||
+ if ((mask & ~all_tiles) != 0 ||
(!region->cells[i].active && mask != 0)) {
return false;
}
@@ -1597,7 +1686,11 @@ static WangSolveStatus solve_wang_core(
WangSolveResult *out_result,
SolverMechanisms mechanisms,
const WangSolveTraceOptions *trace_options,
- WangSolveTrace *out_trace
+ WangSolveTrace *out_trace,
+ const WangTile *tileset,
+ size_t tileset_count,
+ Enumeration *enumeration,
+ const WangGraph *graph
)
{
if (!result_is_destroyed(out_result) ||
@@ -1609,10 +1702,18 @@ static WangSolveStatus solve_wang_core(
return WANG_SOLVE_ERROR;
}
- if (!region_validate(region)) {
+ if (tileset == NULL || tileset_count == 0 ||
+ tileset_count > WANG_RUNTIME_MAX_TILES || !region_validate(region)) {
return WANG_SOLVE_ERROR;
}
- if (!initial_domains_are_valid(region, options)) {
+ for (size_t t = 0; t < tileset_count; ++t) {
+ for (Dir d = N; d < DIR_COUNT; ++d) {
+ if (tileset[t].edge[d] >= COLOR_COUNT) return WANG_SOLVE_ERROR;
+ }
+ }
+ const uint32_t all_tiles = tileset_count == 32u
+ ? UINT32_MAX : (UINT32_C(1) << tileset_count) - UINT32_C(1);
+ if (!initial_domains_are_valid(region, options, all_tiles)) {
return WANG_SOLVE_ERROR;
}
const size_t cell_count = region->cell_count;
@@ -1620,6 +1721,12 @@ static WangSolveStatus solve_wang_core(
SolverState state = {0};
state.writer.fd = -1;
state.region = region;
+ state.graph = graph;
+ state.enumeration = enumeration;
+ state.tileset = tileset;
+ state.tileset_count = tileset_count;
+ state.all_tiles = all_tiles;
+ state.domain_byte_count = (tileset_count + 7u) / 8u;
state.cell_count = cell_count;
state.collect_metrics = options != NULL &&
(options->flags & WANG_SOLVE_COLLECT_METRICS) != 0;
@@ -1628,22 +1735,47 @@ static WangSolveStatus solve_wang_core(
state.record_trail = mechanisms.record_initial_trail;
state.deduplicate_queue = mechanisms.deduplicate_queue;
state.index_mrv = mechanisms.index_mrv;
- build_solver_tables(&state.tables);
- if (!solver_tables_are_valid(&state.tables)) {
+ build_solver_tables(&state.tables, tileset, tileset_count);
+ if (!solver_tables_are_valid(&state.tables, tileset, tileset_count)) {
return WANG_SOLVE_ERROR;
}
+ const size_t compatibility_count = graph ? graph->side_count * graph->side_count : DIR_COUNT;
+ if (graph) {
+ memset(&state.tables, 0, sizeof(state.tables));
+ for (size_t t = 0; t < tileset_count; ++t)
+ for (size_t d = 0; d < graph->side_count; ++d)
+ state.tables.edge_mask[d][graph->edges[t * graph->side_count + d]] |= UINT32_C(1) << t;
+ for (size_t d = 0; d < graph->side_count; ++d)
+ for (size_t e = 0; e < graph->side_count; ++e)
+ for (size_t t = 0; t < tileset_count; ++t)
+ state.tables.compat[d * graph->side_count + e][t] =
+ state.tables.edge_mask[e][graph->edges[t * graph->side_count + d]];
+ }
if (mechanisms.use_bytewise_support) {
- state.byte_support = malloc(sizeof(*state.byte_support));
+ const size_t entries = compatibility_count * state.domain_byte_count *
+ WANG_SUPPORT_BYTE_VALUE_COUNT;
+ state.byte_support = calloc(entries, sizeof(*state.byte_support));
if (state.byte_support == NULL) {
solver_state_destroy(&state);
return WANG_SOLVE_ERROR;
}
- byte_support_tables_build(
- (const ByteSupportCompat *)&state.tables.compat,
- state.byte_support
- );
+ /* Same bytewise union optimization; runtime tile count, no copies of
+ * the search implementation and no canonical tileset dependency. */
+ for (size_t d = 0; d < compatibility_count; ++d) {
+ for (size_t byte = 0; byte < state.domain_byte_count; ++byte) {
+ uint32_t *support = state.byte_support +
+ (d * state.domain_byte_count + byte) * 256u;
+ for (unsigned value = 1; value < 256u; ++value) {
+ const size_t tile = byte * 8u + first_set_tile(value);
+ support[value] = support[value & (value - 1u)];
+ if (tile < tileset_count) {
+ support[value] |= state.tables.compat[d][tile];
+ }
+ }
+ }
+ }
if (state.collect_metrics) {
- state.metrics.support_table_bytes = sizeof(*state.byte_support);
+ state.metrics.support_table_bytes = entries * sizeof(uint32_t);
}
}
@@ -1783,6 +1915,12 @@ static WangSolveStatus solve_wang_core(
}
}
+ if (enumeration != NULL) {
+ if (enumeration->metrics != NULL) *enumeration->metrics = state.metrics;
+ solver_state_destroy(&state);
+ return status;
+ }
+
if (status == WANG_SOLVE_SAT && !verify_sat_domains(&state)) {
status = WANG_SOLVE_ERROR;
}
@@ -1890,7 +2028,8 @@ WangSolveStatus wang_solve_serial(
.index_mrv = false,
},
NULL,
- NULL
+ NULL,
+ TILESET, TILE_COUNT, NULL, NULL
);
}
@@ -1913,7 +2052,8 @@ WangSolveStatus wang_solve_optimized(
.index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0,
},
NULL,
- NULL
+ NULL,
+ TILESET, TILE_COUNT, NULL, NULL
);
}
@@ -1947,7 +2087,8 @@ WangSolveStatus wang_solve_serial_traced(
.index_mrv = false,
},
trace_options,
- &out_result->trace
+ &out_result->trace,
+ TILESET, TILE_COUNT, NULL, NULL
);
}
@@ -1974,6 +2115,121 @@ WangSolveStatus wang_solve_optimized_traced(
.index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0,
},
trace_options,
- &out_result->trace
+ &out_result->trace,
+ TILESET, TILE_COUNT, NULL, NULL
+ );
+}
+
+WangSolveStatus wang_solve_optimized_tileset(
+ const Region *region, const WangTile *tileset, size_t tileset_count,
+ const WangSolverOptions *options, WangSolveResult *out_result
+)
+{
+ if (options != NULL &&
+ (options->flags & ~WANG_SOLVE_COLLECT_METRICS) != 0) {
+ return WANG_SOLVE_ERROR;
+ }
+ return solve_wang_core(
+ region, options, out_result,
+ (SolverMechanisms) {
+ .stack_mode = SEARCH_STACK_DYNAMIC,
+ .record_initial_trail = false,
+ .transfer_sat_domains = true,
+ .use_bytewise_support = true,
+ .deduplicate_queue = WANG_OPTIMIZED_QUEUE_DEDUP != 0,
+ .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0,
+ },
+ NULL, NULL, tileset, tileset_count, NULL, NULL
);
}
+
+int wang_enumerate_optimized_tileset(
+ const Region *region, const WangTile *tileset, size_t tileset_count,
+ const WangSolverOptions *options, WangSolutionVisitor visitor,
+ void *userdata, WangSolverMetrics *metrics
+)
+{
+ if (metrics != NULL) *metrics = (WangSolverMetrics){0};
+ if (visitor == NULL || (options != NULL &&
+ (options->flags & ~WANG_SOLVE_COLLECT_METRICS) != 0)) return -1;
+ Enumeration enumeration = {visitor, userdata, metrics};
+ WangSolveResult unused_result = {0};
+ return (int)solve_wang_core(
+ region, options, &unused_result,
+ (SolverMechanisms) {
+ .stack_mode = SEARCH_STACK_DYNAMIC,
+ .record_initial_trail = false,
+ .transfer_sat_domains = true,
+ .use_bytewise_support = true,
+ .deduplicate_queue = WANG_OPTIMIZED_QUEUE_DEDUP != 0,
+ .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0,
+ }, NULL, NULL, tileset, tileset_count, &enumeration, NULL);
+}
+
+int wang_enumerate_serial_tileset(
+ const Region *region, const WangTile *tileset, size_t tileset_count,
+ const WangSolverOptions *options, WangSolutionVisitor visitor,
+ void *userdata, WangSolverMetrics *metrics
+)
+{
+ if (metrics != NULL) *metrics = (WangSolverMetrics){0};
+ if (visitor == NULL || (options != NULL &&
+ (options->flags & ~WANG_SOLVE_COLLECT_METRICS) != 0)) return -1;
+ Enumeration enumeration = {visitor, userdata, metrics};
+ WangSolveResult unused_result = {0};
+ return (int)solve_wang_core(
+ region, options, &unused_result,
+ (SolverMechanisms) {
+ .stack_mode = SEARCH_STACK_FIXED,
+ .record_initial_trail = true,
+ .transfer_sat_domains = false,
+ .use_bytewise_support = false,
+ .deduplicate_queue = false,
+ .index_mrv = false,
+ }, NULL, NULL, tileset, tileset_count, &enumeration, NULL);
+}
+
+int wang_enumerate_graph(const WangGraph *g, size_t tile_count,
+ const WangSolverOptions *options, bool reference, WangSolutionVisitor visitor,
+ void *userdata, WangSolverMetrics *metrics)
+{
+ if (metrics) *metrics = (WangSolverMetrics){0};
+ if (!g || !visitor || !g->cell_count || g->cell_count > 4096 ||
+ !g->side_count || g->side_count > WANG_GRAPH_MAX_SIDES ||
+ !tile_count || tile_count > WANG_RUNTIME_MAX_TILES ||
+ !g->neighbors || !g->neighbor_sides || !g->edges || !g->boundary ||
+ (options && (options->flags & ~WANG_SOLVE_COLLECT_METRICS))) return -1;
+ for (size_t t = 0; t < tile_count * g->side_count; ++t)
+ if (g->edges[t] >= COLOR_COUNT) return -1;
+ for (size_t i = 0; i < g->cell_count; ++i) {
+ for (size_t d = 0; d < g->side_count; ++d) {
+ const size_t side = i * g->side_count + d;
+ const int32_t n = g->neighbors[side];
+ if (g->boundary[side] != COLOR_NONE && g->boundary[side] >= COLOR_COUNT) return -1;
+ if (n == -1) continue;
+ if (n < 0 || (size_t)n >= g->cell_count || (size_t)n == i ||
+ g->neighbor_sides[side] >= g->side_count) return -1;
+ const size_t back = (size_t)n * g->side_count + g->neighbor_sides[side];
+ if (g->neighbors[back] != (int32_t)i || g->neighbor_sides[back] != d) return -1;
+ }
+ }
+ /* The dense carrier supplies active-cell bookkeeping only. Geometry and
+ * verification use the explicit graph; square entry points are unchanged. */
+ Region region = {0};
+ if (!region_init(®ion, (int32_t)g->cell_count, 1)) return -1;
+ for (size_t i = 0; i < g->cell_count; ++i) region.cells[i].active = true;
+ WangTile carrier[WANG_RUNTIME_MAX_TILES] = {0};
+ Enumeration enumeration = {visitor, userdata, metrics};
+ WangSolveResult unused = {0};
+ const int result = (int)solve_wang_core(®ion, options, &unused,
+ (SolverMechanisms){
+ .stack_mode = reference ? SEARCH_STACK_FIXED : SEARCH_STACK_DYNAMIC,
+ .record_initial_trail = reference,
+ .transfer_sat_domains = !reference,
+ .use_bytewise_support = !reference,
+ .deduplicate_queue = !reference && WANG_OPTIMIZED_QUEUE_DEDUP != 0,
+ .index_mrv = !reference && WANG_OPTIMIZED_MRV_INDEX != 0,
+ }, NULL, NULL, carrier, tile_count, &enumeration, g);
+ region_destroy(®ion);
+ return result;
+}
diff --git a/src/verify/verify_tiling.c b/src/verify/verify_tiling.c
index 8acd564..24bc68f 100644
--- a/src/verify/verify_tiling.c
+++ b/src/verify/verify_tiling.c
@@ -1,15 +1,24 @@
#include "wang/verify.h"
-WangVerifyStatus wang_verify_tiling(
+WangVerifyStatus wang_verify_tiling_with_tileset(
const Region *region,
const TileId *tiles,
- size_t tile_count
+ size_t tile_count,
+ const WangTile *tileset,
+ size_t tileset_count
)
{
- if (region == NULL || tiles == NULL) {
+ if (region == NULL || tiles == NULL || tileset == NULL ||
+ tileset_count == 0 || tileset_count > 32u) {
return WANG_VERIFY_INVALID_ARGUMENT;
}
+ for (size_t t = 0; t < tileset_count; ++t) {
+ for (Dir d = N; d < DIR_COUNT; ++d) {
+ if (tileset[t].edge[d] >= COLOR_COUNT) return WANG_VERIFY_INVALID_ARGUMENT;
+ }
+ }
+
if (!region_validate(region)) {
return WANG_VERIFY_INVALID_REGION;
}
@@ -34,11 +43,11 @@ WangVerifyStatus wang_verify_tiling(
if (tile_id == TILE_NONE) {
return WANG_VERIFY_INCOMPLETE;
}
- if (tile_id >= TILE_COUNT) {
+ if (tile_id >= tileset_count) {
return WANG_VERIFY_INVALID_TILE_ID;
}
- const WangTile *tile = &TILESET[tile_id];
+ const WangTile *tile = &tileset[tile_id];
for (Dir dir = N; dir < DIR_COUNT; ++dir) {
const ColorId required = cell->boundary[dir];
if (required != COLOR_NONE && tile->edge[dir] != required) {
@@ -73,8 +82,8 @@ WangVerifyStatus wang_verify_tiling(
neighbor_x,
neighbor_y
);
- const WangTile *tile = &TILESET[tiles[index]];
- const WangTile *neighbor_tile = &TILESET[tiles[neighbor_index]];
+ const WangTile *tile = &tileset[tiles[index]];
+ const WangTile *neighbor_tile = &tileset[tiles[neighbor_index]];
if (tile->edge[dir] != neighbor_tile->edge[opposite(dir)]) {
return WANG_VERIFY_ADJACENCY_MISMATCH;
@@ -85,3 +94,11 @@ WangVerifyStatus wang_verify_tiling(
return WANG_VERIFY_VALID;
}
+
+WangVerifyStatus wang_verify_tiling(
+ const Region *region, const TileId *tiles, size_t tile_count
+)
+{
+ return wang_verify_tiling_with_tileset(
+ region, tiles, tile_count, TILESET, TILE_COUNT);
+}
diff --git a/tests/benchmark_solver_web.mjs b/tests/benchmark_solver_web.mjs
new file mode 100644
index 0000000..9de84e3
--- /dev/null
+++ b/tests/benchmark_solver_web.mjs
@@ -0,0 +1,40 @@
+// Usage: node tests/benchmark_solver_web.mjs OLD_PACKAGE NEW_PACKAGE
+// Alternate modules within each sample to avoid systematic warmup/thermal bias.
+import {resolve} from 'node:path';
+import {pathToFileURL} from 'node:url';
+import {readFile} from 'node:fs/promises';
+import assert from 'node:assert/strict';
+import {prepare} from '../packages/solver-web/prepare.mjs';
+const canonical=JSON.parse(await readFile(new URL('./web_yang_zhang.json',import.meta.url)));
+const engines=[];
+for(const directory of process.argv.slice(2)) {
+ const {default:create}=await import(pathToFileURL(resolve(directory)+'/foundry.mjs'));
+ engines.push({directory,module:await create(),cases:{}});
+}
+for(const [name,tiles] of [['yang-zhang-23',canonical],['32',Array.from({length:32},(_,i)=>[i%4,Math.floor(i/4)%2,0,Math.floor(i/8)])]]) {
+ for(const solver of ['reference','optimized']) {
+ const p=prepare({geometry:'square',palette:Array(16).fill('#ffffff'),tiles,solver,
+ cells:Array.from({length:4},(_,i)=>({id:`${i%2},${Math.floor(i/2)}`,required:[null,null,null,null]}))});
+ const runners=engines.map(engine=>{
+ const module=engine.module,pointers=[];
+ const alloc=a=>{const x=module._malloc(a.byteLength);pointers.push(x);module.HEAPU8.set(new Uint8Array(a.buffer),x);return x;};
+ const args=[p.width,p.height,alloc(p.active),alloc(p.boundary),alloc(p.edges),tiles.length,alloc(p.domains),Number(solver==='reference')];
+ let count=0;module.onSolution=()=>{count++;return 0;};
+ const run=()=>{count=0;assert.equal(module._foundry_enumerate(...args),0);return count;};
+ const expected=run();for(let i=0;i<200;i++)assert.equal(run(),expected);
+ return {engine,run,pointers,expected,times:[]};
+ });
+ assert.ok(runners.every(r=>r.expected===runners[0].expected));
+ for(let sample=0;sample<9;sample++)for(const r of sample%2?[...runners].reverse():runners) {
+ const start=performance.now();for(let i=0;i<1000;i++)r.run();r.times.push(performance.now()-start);
+ }
+ for(const r of runners) {
+ r.times.sort((a,b)=>a-b);
+ r.engine.cases[name+' '+solver]={median_ms_per_1000:r.times[4],solutions:r.expected};
+ r.pointers.forEach(x=>r.engine.module._free(x));
+ }
+ }
+}
+console.log(JSON.stringify(engines.map(({directory,cases})=>({directory,cases})),null,2));
+if(engines.length===2)for(const key of Object.keys(engines[0].cases))
+ console.log(key+': new/old '+(engines[1].cases[key].median_ms_per_1000/engines[0].cases[key].median_ms_per_1000).toFixed(3));
diff --git a/tests/c/test_solver_enumeration.c b/tests/c/test_solver_enumeration.c
new file mode 100644
index 0000000..f943e5c
--- /dev/null
+++ b/tests/c/test_solver_enumeration.c
@@ -0,0 +1,63 @@
+#include "wang/solver.h"
+#include
+#include
+
+typedef struct { size_t count, limit; uint32_t last; } Capture;
+static bool visit(const uint32_t *domains, size_t n,
+ const WangSolverMetrics *metrics, void *userdata)
+{
+ Capture *capture = userdata;
+ assert(domains != NULL && n > 0 && metrics != NULL);
+ ++capture->count;
+ capture->last = domains[0];
+ return capture->limit == 0 || capture->count < capture->limit;
+}
+
+int main(void)
+{
+ Region region = {0};
+ assert(region_init(®ion, 1, 1));
+ assert(region_set_active(®ion, 0, 0, true));
+ WangTile tiles[3] = {{.edge={0,0,0,0}}, {.edge={1,1,1,1}}, {.edge={0,0,0,0}}};
+ Capture c = {0};
+ assert(wang_enumerate_optimized_tileset(®ion,tiles,3,NULL,visit,&c,NULL)==0);
+ assert(c.count==2); /* Isolated choices cannot be collapsed to one. */
+ uint32_t domain=4; /* An allowed duplicate must remain usable. */
+ WangSolverOptions options={.initial_domains=&domain,.initial_domain_count=1};
+ c=(Capture){0};
+ assert(wang_enumerate_optimized_tileset(®ion,tiles,3,&options,visit,&c,NULL)==0);
+ assert(c.count==1 && c.last==4);
+ c=(Capture){.limit=1};
+ assert(wang_enumerate_optimized_tileset(®ion,tiles,3,NULL,visit,&c,NULL)==1);
+ assert(c.count==1);
+ domain=0;c=(Capture){0};
+ assert(wang_enumerate_optimized_tileset(®ion,tiles,3,&options,visit,&c,NULL)==0);
+ assert(c.count==0);
+ region_destroy(®ion);
+
+ assert(region_init(®ion,2,2));
+ WangTile binary[16]={0};
+ for (unsigned t=0;t<16;t++) for (unsigned d=0;d<4;d++) binary[t].edge[d]=(t>>d)&1u;
+ for(int y=0;y<2;y++) for(int x=0;x<2;x++) assert(region_set_active(®ion,x,y,true));
+ for(int y=0;y<2;y++) for(int x=0;x<2;x++) {
+ if(y==0) assert(region_set_boundary(®ion,x,y,N,0));
+ if(y==1) assert(region_set_boundary(®ion,x,y,S,0));
+ if(x==0) assert(region_set_boundary(®ion,x,y,W,0));
+ if(x==1) assert(region_set_boundary(®ion,x,y,E,0));
+ }
+ c=(Capture){0};
+ WangSolverMetrics metrics={0};
+ options=(WangSolverOptions){.flags=WANG_SOLVE_COLLECT_METRICS};
+ assert(wang_enumerate_optimized_tileset(®ion,binary,16,&options,visit,&c,&metrics)==0);
+ assert(c.count==16 && metrics.backtracks>0);
+ c=(Capture){0};
+ metrics=(WangSolverMetrics){0};
+ assert(wang_enumerate_serial_tileset(®ion,binary,16,&options,visit,&c,&metrics)==0);
+ assert(c.count==16 && metrics.backtracks>0);
+ c=(Capture){.limit=1};
+ assert(wang_enumerate_serial_tileset(®ion,binary,16,&options,visit,&c,NULL)==1);
+ assert(c.count==1);
+ region_destroy(®ion);
+ puts("test_solver_enumeration: OK");
+ return 0;
+}
diff --git a/tests/c/test_solver_graph.c b/tests/c/test_solver_graph.c
new file mode 100644
index 0000000..ecced32
--- /dev/null
+++ b/tests/c/test_solver_graph.c
@@ -0,0 +1,48 @@
+#include "wang/solver.h"
+#include
+#include
+
+static bool visit(const uint32_t *domains, size_t n,
+ const WangSolverMetrics *metrics, void *userdata)
+{
+ (void)metrics;
+ assert(n == 2 && domains[0] && domains[1]);
+ ++*(int *)userdata;
+ return true;
+}
+
+int main(void)
+{
+ /* Different side numbers exercise explicit arcs, independent of geometry. */
+ int32_t neighbors[12] = {-1,-1,-1,-1,-1,1, 0,-1,-1,-1,-1,-1};
+ uint8_t sides[12] = {0,0,0,0,0,0, 5,0,0,0,0,0};
+ uint8_t boundary[12];
+ for (size_t i = 0; i < 12; ++i) boundary[i] = COLOR_NONE;
+ const uint8_t edges[18] = {0,0,0,0,0,0, 1,1,1,1,1,1, 0,0,0,0,0,0};
+ WangGraph graph = {2,6,neighbors,sides,boundary,edges};
+ for (int reference = 0; reference < 2; ++reference) {
+ int count = 0;
+ assert(wang_enumerate_graph(&graph,3,NULL,reference,visit,&count,NULL) == 0);
+ assert(count == 2); /* Duplicate tuples collapse. */
+ uint32_t domains[2] = {4,1};
+ WangSolverOptions options = {.initial_domains=domains,.initial_domain_count=2};
+ count = 0;
+ assert(wang_enumerate_graph(&graph,3,&options,reference,visit,&count,NULL) == 0);
+ assert(count == 1); /* A duplicate pinned by ID remains available. */
+ boundary[5] = 1;
+ count = 0;
+ assert(wang_enumerate_graph(&graph,3,&options,reference,visit,&count,NULL) == 0);
+ assert(count == 0);
+ boundary[5] = COLOR_NONE;
+ }
+ int count = 0;
+ sides[6] = 4;
+ assert(wang_enumerate_graph(&graph,3,NULL,false,visit,&count,NULL) == -1);
+ sides[6] = 5;
+ neighbors[5] = 2;
+ assert(wang_enumerate_graph(&graph,3,NULL,false,visit,&count,NULL) == -1);
+ neighbors[5] = 1;
+ graph.side_count = 7;
+ assert(wang_enumerate_graph(&graph,3,NULL,false,visit,&count,NULL) == -1);
+ puts("test_solver_graph: OK");
+}
diff --git a/tests/c/test_solver_runtime_tileset.c b/tests/c/test_solver_runtime_tileset.c
new file mode 100644
index 0000000..c4dc3ef
--- /dev/null
+++ b/tests/c/test_solver_runtime_tileset.c
@@ -0,0 +1,57 @@
+#include "wang/solver.h"
+#include "wang/verify.h"
+#include
+#include
+
+static Region rectangle(int width, int height)
+{
+ Region r = {0};
+ assert(region_init(&r, width, height));
+ for (int y = 0; y < height; ++y)
+ for (int x = 0; x < width; ++x)
+ assert(region_set_active(&r, x, y, true));
+ return r;
+}
+
+int main(void)
+{
+ WangTile alternating[2] = {
+ {.edge = {0, 1, 0, 0}}, {.edge = {0, 0, 0, 1}}
+ };
+ Region r = rectangle(2, 1);
+ assert(region_set_boundary(&r, 0, 0, W, 0));
+ assert(region_set_boundary(&r, 1, 0, E, 0));
+ WangSolveResult out = {0};
+ assert(wang_solve_optimized_tileset(&r, alternating, 2, NULL, &out) == WANG_SOLVE_SAT);
+ assert(out.domains[0] == 1 && out.domains[1] == 2);
+ const TileId solution[2] = {0, 1};
+ assert(wang_verify_tiling_with_tileset(&r, solution, 2, alternating, 2) == WANG_VERIFY_VALID);
+ wang_solve_result_destroy(&out);
+ assert(region_set_boundary(&r, 1, 0, E, 1));
+ assert(wang_solve_optimized_tileset(&r, alternating, 2, NULL, &out) == WANG_SOLVE_UNSAT);
+ wang_solve_result_destroy(&out);
+ region_destroy(&r);
+
+ /* Exercise bit 31, four-byte support and the largest MRV domain. */
+ WangTile full[32] = {0};
+ for (int d = 0; d < 4; ++d) full[31].edge[d] = 1;
+ r = rectangle(3, 2);
+ uint32_t domains[6] = {UINT32_MAX, UINT32_MAX, UINT32_MAX,
+ UINT32_MAX, UINT32_MAX, UINT32_C(1) << 31};
+ WangSolverOptions options = {.flags = WANG_SOLVE_COLLECT_METRICS,
+ .initial_domains = domains, .initial_domain_count = 6};
+ assert(wang_solve_optimized_tileset(&r, full, 32, &options, &out) == WANG_SOLVE_SAT);
+ for (int i = 0; i < 6; ++i) assert(out.domains[i] == (UINT32_C(1) << 31));
+ assert(out.metrics.support_table_bytes == 4u * 4u * 256u * sizeof(uint32_t));
+ wang_solve_result_destroy(&out);
+ assert(wang_solve_optimized_tileset(&r, full, 32, NULL, &out) == WANG_SOLVE_SAT);
+ wang_solve_result_destroy(&out);
+ assert(wang_solve_optimized_tileset(&r, full, 0, NULL, &out) == WANG_SOLVE_ERROR);
+ assert(wang_solve_optimized_tileset(&r, full, 33, NULL, &out) == WANG_SOLVE_ERROR);
+ assert(wang_solve_optimized_tileset(&r, full, 1, &options, &out) == WANG_SOLVE_ERROR);
+ full[0].edge[0] = COLOR_COUNT;
+ assert(wang_solve_optimized_tileset(&r, full, 32, NULL, &out) == WANG_SOLVE_ERROR);
+ region_destroy(&r);
+ puts("test_solver_runtime_tileset: OK");
+ return 0;
+}
diff --git a/tests/python/test_generated_pages.py b/tests/python/test_generated_pages.py
index ef53891..ee57579 100644
--- a/tests/python/test_generated_pages.py
+++ b/tests/python/test_generated_pages.py
@@ -148,6 +148,30 @@ def test_accepts_all_routes_and_resolved_targets(self) -> None:
self.assertEqual(result.errors, ())
self.assertEqual(result.page_count, 41)
+ def test_sibling_project_links_and_internal_link_validation(self) -> None:
+ cases = (
+ (f"{SITE_URL}/wang-studio/", None),
+ ("//xtraid.github.io/wang-studio/", None),
+ ("/wang-studio/", "internal URL escapes baseurl"),
+ (f"{SITE_URL}{BASEURL}/missing/", "unresolved href target"),
+ )
+ for target, expected_error in cases:
+ with self.subTest(target=target), tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ _valid_site(root)
+ home = root / "index.html"
+ home.write_text(
+ home.read_text(encoding="utf-8").replace(
+ "", f'Project'
+ ),
+ encoding="utf-8",
+ )
+ result = check_site(root, site_url=SITE_URL, baseurl=BASEURL)
+ if expected_error is None:
+ self.assertEqual(result.errors, ())
+ else:
+ self.assertIn(expected_error, "\n".join(result.errors))
+
def test_rejects_the_known_home_regression_and_broken_links(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
diff --git a/tests/test_solver_web.mjs b/tests/test_solver_web.mjs
new file mode 100644
index 0000000..b0d3b6c
--- /dev/null
+++ b/tests/test_solver_web.mjs
@@ -0,0 +1,231 @@
+import assert from 'node:assert/strict';
+import {pathToFileURL} from 'node:url';
+import {resolve} from 'node:path';
+
+const root = resolve(process.argv[2] || 'build/solver-web/package');
+const {default: createModule} = await import(pathToFileURL(root + '/foundry.mjs'));
+const {prepare, checkAssignments} = await import(pathToFileURL(root + '/prepare.mjs'));
+const module = await createModule();
+// Independent polygon edge matching, not the package's lattice-neighbor table.
+function polygon(data, id) {
+ const [i,j,h=0]=id.split(',').map(Number), g=data.geometry;
+ if(g==='square') return [[i,j],[i+1,j],[i+1,j+1],[i,j+1]];
+ if(g.startsWith('tri')) {
+ const x=i+j/2,y=j*Math.sqrt(3)/2;
+ const p=h?[[x+1.5,y+Math.sqrt(3)/2],[x+.5,y+Math.sqrt(3)/2],[x+1,y]]
+ :[[x,y],[x+1,y],[x+.5,y+Math.sqrt(3)/2]];
+ return g==='tri-side'?p.map(([x,y])=>[-y,x]):p;
+ }
+ const flat=g==='hex-flat',x=flat?1.5*i:Math.sqrt(3)*(i+j/2),y=flat?Math.sqrt(3)*(j+i/2):1.5*j;
+ return Array.from({length:6},(_,d)=>[x+Math.cos((flat?0:-Math.PI/2)+d*Math.PI/3),y+Math.sin((flat?0:-Math.PI/2)+d*Math.PI/3)]);
+}
+function edgeKeys(data,id) {
+ const p=polygon(data,id).map(p=>p.map(x=>Math.round(x*1e5)).join(','));
+ return p.map((v,d)=>[v,p[(d+1)%p.length]].sort().join('|'));
+}
+
+function brute(data) {
+ const results = new Set(), placed = {};
+ const keys = Object.fromEntries(data.cells.map(c=>[c.id,edgeKeys(data,c.id)]));
+ const visit = index => {
+ if (index === data.cells.length) {
+ results.add(JSON.stringify(data.cells.map(c => placed[c.id])));
+ return;
+ }
+ const cell = data.cells[index], [x,y] = cell.id.split(',').map(Number);
+ for (const tile of new Map(data.tiles.map(t=>[JSON.stringify(t),t])).values()) {
+ if (cell.fixed && tile.some((v,d) => v !== cell.fixed[d])) continue;
+ if (cell.required.some((v,d) => v !== null && tile[d] !== v)) continue;
+ if (Object.entries(placed).some(([id,other])=>keys[cell.id].some((key,d)=>{
+ const side=keys[id].indexOf(key);return side>=0 && tile[d]!==other[side];
+ }))) continue;
+ placed[cell.id] = tile;
+ visit(index+1);
+ delete placed[cell.id];
+ }
+ };
+ visit(0);
+ return [...results].sort();
+}
+
+async function enumerate(data, pause) {
+ const prepared = prepare(data), pointers = [], results = [];
+ const allocate = array => {
+ const pointer = module._malloc(array.byteLength);
+ pointers.push(pointer);
+ module.HEAPU8.set(new Uint8Array(array.buffer),pointer);
+ return pointer;
+ };
+ module.onSolution = domains => {
+ const assigned = Object.fromEntries([...prepared.indices].map(([id,index]) =>
+ [id,Array.from({length:prepared.words},(_,w)=>module.HEAPU32[(domains>>>2)+index*prepared.words+w]).reduce((t,b,w)=>b?w*32+31-Math.clz32(b):t,-1)]));
+ checkAssignments(prepared,assigned);
+ results.push(JSON.stringify(data.cells.map(c => data.tiles[assigned[c.id]])));
+ return pause ? 1 : 0;
+ };
+ module.onWait = async () => { await new Promise(r => setTimeout(r,5)); return 1; };
+ try {
+ const args = prepared.graph ? [prepared.records.size,prepared.sides,allocate(prepared.neighbors),
+ allocate(prepared.neighborSides),allocate(prepared.boundary),allocate(prepared.edges),
+ prepared.tiles.length,allocate(prepared.domains),Number(prepared.solver==='reference'),Number(prepared.wide)] : [prepared.width,prepared.height,allocate(prepared.active),
+ allocate(prepared.boundary),allocate(prepared.edges),prepared.tiles.length,
+ allocate(prepared.domains),Number(prepared.solver==='reference')];
+ assert.equal(await module.ccall(prepared.graph?'foundry_enumerate_graph':'foundry_enumerate','number',Array(args.length).fill('number'),args,{async:true}),0);
+ assert.equal(new Set(results).size,results.length,'No repeated edge configurations');
+ return results.sort();
+ } finally { pointers.forEach(p => module._free(p)); }
+}
+
+const instance = (tiles,width=2,height=2) => ({geometry:'square',palette:['#ffffff','#000000'],tiles,
+ cells:Array.from({length:width*height},(_,i)=>({id:`${i%width},${Math.floor(i/width)}`,required:[null,null,null,null]}))});
+let seed = 17821;
+const random = n => { seed=(Math.imul(seed,1664525)+1013904223)>>>0; return Math.floor(seed/4294967296*n); };
+for(let i=0;i<100;i++) {
+ const data = instance(Array.from({length:1+random(4)},()=>Array.from({length:4},()=>random(2))));
+ for (const cell of data.cells) cell.required=Array.from({length:4},()=>[null,null,0,1][random(4)]);
+ if(i%3===0)data.cells[0].fixed=data.tiles[0];
+ const expected=brute(data);
+ for (const solver of ['optimized','reference']) assert.deepEqual(await enumerate({...data,solver}),expected);
+}
+for (const solver of ['optimized','reference']) {
+ const data=instance([[0,0,0,0],[1,1,1,1],[0,0,0,0]],1,1);
+ data.solver=solver;
+ data.cells.push({id:'-3,-3',required:[null,null,null,null]});
+ assert.deepEqual(await enumerate(data,true),brute(data),'Asyncify pause preserves native enumeration');
+ const bit31=instance(Array.from({length:32},(_,n)=>[n%16,Math.floor(n/16),0,0]),1,1);
+ bit31.solver=solver;
+ bit31.palette=Array(16).fill('#ffffff');
+ bit31.cells[0].fixed=bit31.tiles[31];
+ assert.deepEqual(await enumerate(bit31),brute(bit31),'Bit 31 and symbolic equal-RGB colors');
+ const hole=instance([[0,0,0,0]],3,3);
+ hole.cells=hole.cells.filter(c=>c.id!=='1,1');
+ assert.deepEqual(await enumerate({...hole,solver}),brute(hole));
+}
+assert.throws(()=>prepare({...instance([[0,0,0,0]]),geometry:'hex-flat'}));
+assert.throws(()=>prepare({...instance([[0,0,0,0]]),timeout_seconds:NaN}));
+const duplicate=instance([[0,0,0,0]],1,1);
+duplicate.cells.push({id:'00,0',required:[null,null,null,null]});
+assert.throws(()=>prepare(duplicate),/duplicate/);
+console.log('PASS: C reference and optimized Wasm vs exhaustive enumeration on 100 instances, async pause, duplicates, disconnected regions, holes, bit 31, validation.');
+
+for(const geometry of ['tri-up','tri-side','hex-flat','hex-point']) {
+ const sides=geometry.startsWith('tri')?3:6;
+ const ids=sides===3?['-1,0,0','-1,0,1','0,0,0','0,0,1','0,-1,1']:['-1,0','0,0','0,1','1,0'];
+ const base={geometry,palette:['#ffffff','#000000'],tiles:[Array(sides).fill(0),Array(sides).fill(1),Array(sides).fill(0)],
+ cells:ids.map(id=>({id,required:Array(sides).fill(null)}))};
+ for(const solver of ['optimized','reference']) {
+ assert.deepEqual(await enumerate({...base,solver},true),brute(base));
+ const disconnected={...base,solver,cells:[base.cells[0],{id:sides===3?'9,9,1':'9,9',required:Array(sides).fill(null)}]};
+ assert.equal((await enumerate(disconnected)).length,4);
+ const bit31={...base,solver,palette:Array(16).fill('#ffffff'),
+ tiles:Array.from({length:32},(_,n)=>[n%16,Math.floor(n/16),...Array(sides-2).fill(0)]),cells:[base.cells[0]]};
+ bit31.cells=[{...base.cells[0],fixed:bit31.tiles[31]}];
+ assert.deepEqual(await enumerate(bit31),brute(bit31));
+ }
+ for(let trial=0;trial<100;trial++) {
+ const data={...base,tiles:Array.from({length:1+random(5)},()=>Array.from({length:sides},()=>random(2))),
+ cells:base.cells.map(c=>({...c,required:Array.from({length:sides},()=>random(5)===0?random(2):null)}))};
+ if(trial%3===0)data.cells[0].fixed=data.tiles[random(data.tiles.length)];
+ const expected=brute(data);
+ for(const solver of ['optimized','reference'])assert.deepEqual(await enumerate({...data,solver}),expected,geometry+' trial '+trial);
+ }
+ assert.throws(()=>prepare({...base,cells:[{...base.cells[0],id:'0,0,2'}]}));
+ assert.throws(()=>checkAssignments(prepare(base),{}));
+}
+console.log('PASS: all four triangular/hexagonal variants, 400 random instances against independent polygon oracle, both C engines, pause, fixed tiles, duplicates, disconnected cells and bit 31.');
+
+for (const n of [23,31,32,33,64,65,96,97,257,1025]) {
+ for (const geometry of ['square','tri-up','tri-side','hex-flat','hex-point']) {
+ const sides=geometry.startsWith('tri')?3:geometry==='square'?4:6;
+ const data={geometry,palette:Array(16).fill('#ffffff'),
+ tiles:Array.from({length:n},(_,t)=>Array.from({length:sides},(_,d)=>Math.floor(t/16**d)%16)),
+ cells:[{id:sides===3?'0,0,0':'0,0',required:Array(sides).fill(null)}]};
+ for (const solver of ['reference','optimized']) {
+ data.solver=solver;
+ assert.deepEqual(await enumerate(data),brute(data),`${geometry} ${n} enumeration`);
+ data.cells[0].fixed=data.tiles[n-1];
+ assert.deepEqual(await enumerate(data,true),brute(data),`${n} high ID fixed`);
+ data.cells[0].required[sides-1]=15;
+ assert.deepEqual(await enumerate(data),[],`${n} UNSAT`);
+ data.cells[0].required.fill(null);delete data.cells[0].fixed;
+ }
+ }
+}
+for (let trial=0;trial<100;trial++) {
+ const data=instance(Array.from({length:65},()=>Array.from({length:4},()=>random(2))));
+ for (const cell of data.cells) cell.required=Array.from({length:4},()=>random(6)===0?random(2):null);
+ const expected=brute(data);
+ for (const solver of ['reference','optimized']) assert.deepEqual(await enumerate({...data,solver}),expected,`dynamic oracle ${trial}`);
+}
+console.log('PASS: runtime cardinalities 23/31/32/33/64/65/96/97/257/1025, all geometries, SAT/UNSAT, high IDs, pause, 100 dynamic differential cases.');
+
+// Canonical fixture emitted from TILESET in src/core/tile.c, without modifying it.
+const {readFile}=await import('node:fs/promises');
+const canonical=JSON.parse(await readFile(new URL('./web_yang_zhang.json',import.meta.url)));
+assert.equal(canonical.length,23);
+for(const solver of ['reference','optimized']) {
+ const data={...instance(canonical),solver,palette:Array(16).fill('#ffffff')};
+ assert.deepEqual(await enumerate(data),brute(data),'canonical Yang–Zhang');
+ data.cells[0].required=[15,15,15,15];
+ assert.deepEqual(await enumerate(data),[],'canonical UNSAT');
+}
+console.log('PASS: canonical Yang–Zhang 23-tile SAT/UNSAT against independent oracle.');
+
+// Explicit cycles force branching: arc consistency alone cannot reject an odd
+// cycle of opposite colors. A final uniform tile provides a SAT branch after
+// failed choices, proving undo restores words before the published witness.
+for(const n of [33,64,65,96,97,257,1025])for(const sat of [false,true]) {
+ const words=Math.ceil(n/32),cells=3,ptrs=[];
+ const alloc=a=>{const p=module._malloc(a.byteLength);assert.ok(p);ptrs.push(p);module.HEAPU8.set(new Uint8Array(a.buffer),p);return p;};
+ const neighbors=Int32Array.from([2,1,0,2,1,0]),sides=Uint8Array.from([1,0,1,0,1,0]);
+ const edges=new Uint8Array(n*2),domains=new Uint32Array(cells*words);
+ edges.set([0,1,1,0,2,2],(n-3)*2);
+ for(let c=0;c{
+ count++;
+ for(let c=0;c>>2)+c*words+w],w===Math.floor((n-1)/32)?(2**((n-1)%32))>>>0:0);
+ return 0;
+ };
+ try {
+ const args=[cells,2,alloc(neighbors),alloc(sides),alloc(new Uint8Array(6).fill(255)),alloc(edges),n,alloc(domains),1];
+ assert.equal(await module.ccall('foundry_enumerate_graph','number',Array(9).fill('number'),args,{async:true}),0);
+ assert.equal(count,sat?1:0);
+ }finally{ptrs.forEach(p=>module._free(p));}
+}
+assert.equal(module._malloc(0xffffffff),0,'Wasm resource exhaustion returns null, not abort');
+const recovered=module._malloc(16);assert.ok(recovered);module._free(recovered);
+console.log('PASS: actual Wasm arc-consistent UNSAT cycles, failed branches then SAT, undo across words and recoverable memory exhaustion.');
+
+// Cross the former color-mask and byte-sentinel boundaries on adjacent cells.
+for (const colors of [17, 33, 256, 257, 1025]) {
+ for (const geometry of ['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point']) {
+ const sides = geometry.startsWith('tri') ? 3 : geometry === 'square' ? 4 : 6;
+ const data = {geometry, palette: Array(colors).fill('#ffffff'),
+ tiles: Array.from({length: colors}, (_,t) => Array(sides).fill(t)),
+ cells: (sides === 3 ? ['0,0,0','0,0,1'] : ['0,0','1,0']).map(id =>
+ ({id, required: Array(sides).fill(null)}))};
+ for (const solver of ['reference', 'optimized']) {
+ data.solver = solver;
+ data.cells[0].fixed = data.tiles[colors-1];
+ assert.deepEqual(await enumerate(data,true), brute(data), `${geometry} ${colors} colors`);
+ data.cells[1].required[0] = 0;
+ assert.deepEqual(await enumerate(data), [], 'Wide color conflict');
+ data.cells[1].required.fill(null);
+ }
+ }
+}
+const sparse = instance([[0,0,0,0]],1,1);
+sparse.cells.push({id:'1000000,-1000000',required:[null,null,null,null]});
+assert.deepEqual(await enumerate(sparse),brute(sparse),'Sparse square bypasses rectangular budget');
+const largePalette = {...instance([[65535,65535,65535,65535]],1,1),palette:Array(65536).fill('#ffffff')};
+assert.deepEqual(await enumerate(largePalette),brute(largePalette),'Last supported palette index');
+assert.throws(()=>prepare({...largePalette,palette:Array(65537).fill('#ffffff')}),/Palette/);
+assert.throws(()=>prepare({...sparse,timeout_seconds:2147484}),/Tempo/);
+assert.throws(()=>prepare({...sparse,idle_timeout_seconds:-1}),/Tempo/);
+assert.equal(prepare({...sparse,timeout_seconds:0}).timeout,0);
+console.log('PASS: wide symbolic colors, 255 sentinel separation, adjacent SAT/UNSAT, all geometries, sparse regions and operational limits.');
+const largeRegion = instance([[0,0,0,0]],4097,1);
+assert.equal(prepare(largeRegion).graph,true);
+assert.equal((await enumerate(largeRegion)).length,1,'More than 4096 active cells');
+assert.throws(()=>prepare({...largeRegion,cells:Array(65537).fill(largeRegion.cells[0])}),/65536/);
diff --git a/tests/test_web_dynamic.c b/tests/test_web_dynamic.c
new file mode 100644
index 0000000..9246a6b
--- /dev/null
+++ b/tests/test_web_dynamic.c
@@ -0,0 +1,112 @@
+/* Standalone test of the browser-only implementation, with allocation faults.
+ * cc -Iinclude -std=c17 -Wall -Wextra -Werror -fsanitize=address,undefined
+ * tests/test_web_dynamic.c -o build/test_web_dynamic */
+#include
+#include
+#include
+#include
+static size_t calls, fail_at = SIZE_MAX;
+static void *test_calloc(size_t n, size_t z) { return calls++ == fail_at ? NULL : calloc(n, z); }
+static void *test_realloc(void *p, size_t z) { return calls++ == fail_at ? NULL : realloc(p, z); }
+#define calloc test_calloc
+#define realloc test_realloc
+#include "../packages/solver-web/dynamic.c"
+#undef calloc
+#undef realloc
+static size_t solutions;
+static bool visit(const uint32_t *d, size_t n, const WangSolverMetrics *m, void *u)
+{
+ (void)d;
+ (void)n;
+ (void)u;
+ (void)m;
+ ++solutions;
+ return true;
+}
+static void ring(size_t tiles, size_t cells)
+{
+ size_t words = (tiles + 31) / 32;
+ uint8_t *edges = calloc(tiles * 2, 1);
+ uint32_t *initial = calloc(cells * words, sizeof(uint32_t));
+ int32_t *neighbors = calloc(cells * 2, sizeof(int32_t));
+ uint8_t *sides = calloc(cells * 2, 1), *boundary = calloc(cells * 2, 1);
+ assert(edges && initial && neighbors && sides && boundary);
+ edges[(tiles - 2) * 2] = 0;
+ edges[(tiles - 2) * 2 + 1] = 1;
+ edges[(tiles - 1) * 2] = 1;
+ edges[(tiles - 1) * 2 + 1] = 0;
+ for (size_t c = 0; c < cells; c++) {
+ neighbors[c * 2] = (int32_t)((c + cells - 1) % cells);
+ neighbors[c * 2 + 1] = (int32_t)((c + 1) % cells);
+ sides[c * 2] = 1;
+ sides[c * 2 + 1] = 0;
+ boundary[c * 2] = boundary[c * 2 + 1] = 255;
+ for (size_t t = tiles - 2; t < tiles; t++)
+ initial[c * words + t / 32] |= UINT32_C(1) << (t % 32);
+ }
+ FoundryWebGraph g = {cells, 2, neighbors, sides, boundary, edges, false};
+ calls = solutions = 0;
+ fail_at = SIZE_MAX;
+ assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == 0);
+ assert(solutions == (cells % 2 ? 0 : 2));
+ size_t allocations = calls;
+ for (size_t fail = 0; fail < allocations; fail++) {
+ calls = solutions = 0;
+ fail_at = fail;
+ assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == -1);
+ }
+ fail_at = SIZE_MAX;
+ if (tiles % 32) {
+ initial[words - 1] |= UINT32_C(1) << (tiles % 32);
+ assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == -1);
+ }
+ free(edges);
+ free(initial);
+ free(neighbors);
+ free(sides);
+ free(boundary);
+}
+static void wide_colors(void)
+{
+ const int32_t neighbors[] = {1, 0};
+ const uint8_t sides[] = {0, 0};
+ uint32_t edges[] = {255, 65535};
+ uint32_t boundary[] = {UINT32_MAX, UINT32_MAX};
+ const uint32_t initial[] = {3, 3};
+ FoundryWebGraph g = {2, 1, neighbors, sides, boundary, edges, true};
+ solutions = calls = 0;
+ assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == 0);
+ assert(solutions == 2);
+ const size_t allocations = calls;
+ for (size_t fail = 0; fail < allocations; ++fail) {
+ calls = 0;
+ fail_at = fail;
+ assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == -1);
+ }
+ fail_at = SIZE_MAX;
+ boundary[0] = 255;
+ solutions = 0;
+ assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == 0);
+ assert(solutions == 1); /* 255 is a color, not the wide sentinel. */
+ boundary[1] = 65535;
+ solutions = 0;
+ assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == 0);
+ assert(solutions == 0);
+ boundary[1] = 65536;
+ assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == -1);
+ boundary[1] = UINT32_MAX;
+ edges[1] = 65536;
+ assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == -1);
+}
+int main(void)
+{
+ const size_t counts[] = {23, 31, 32, 33, 64, 65, 96, 97, 257, 1025};
+ for (size_t i = 0; i < sizeof(counts) / sizeof(*counts); i++) {
+ ring(counts[i], 3);
+ ring(counts[i], 4);
+ }
+ ring(65, 1024); /* Grow the undo trail repeatedly; fail each realloc. */
+ wide_colors();
+ puts("PASS dynamic: arc-consistent odd-cycle UNSAT, even-cycle SAT, rollback, high IDs, "
+ "invalid tail bits, every allocation fault.");
+}
diff --git a/tests/web_yang_zhang.json b/tests/web_yang_zhang.json
new file mode 100644
index 0000000..77dfdb0
--- /dev/null
+++ b/tests/web_yang_zhang.json
@@ -0,0 +1,25 @@
+[
+[0,2,7,1],
+[7,2,8,1],
+[8,2,0,1],
+[0,3,0,1],
+[0,4,0,2],
+[0,4,9,3],
+[9,3,0,2],
+[0,2,0,2],
+[0,3,0,3],
+[5,2,5,2],
+[5,3,5,3],
+[0,10,6,2],
+[6,2,0,10],
+[0,11,6,3],
+[6,3,0,11],
+[6,2,12,2],
+[12,2,5,2],
+[6,2,13,3],
+[13,3,5,2],
+[6,3,14,2],
+[14,2,5,3],
+[6,3,15,3],
+[15,3,5,3]
+]
diff --git a/tools/build_solver_web.py b/tools/build_solver_web.py
new file mode 100644
index 0000000..15f7447
--- /dev/null
+++ b/tools/build_solver_web.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""Build an independently consumable, fingerprinted browser solver package."""
+import argparse
+import hashlib
+import gzip
+import json
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import tarfile
+
+ROOT = Path(__file__).resolve().parents[1]
+PACKAGE = ROOT / 'packages/solver-web'
+SOURCES = [
+ 'src/core/tile.c', 'src/core/region.c', 'src/solver/solver_serial.c',
+ 'src/solver/byte_support_table.c', 'src/solver/failed_leaf_trace.c',
+ 'src/solver/solver_event_trace.c', 'src/verify/verify_tiling.c',
+ 'packages/solver-web/bridge.c', 'packages/solver-web/dynamic.c',
+]
+FLAGS = [
+ '-std=c17', '-Wall', '-Wextra', '-Werror', '-O3', '-flto', '-Iinclude', '--no-entry',
+ '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=worker,node',
+ '-sASYNCIFY=1', '-sASYNCIFY_STACK_SIZE=65536',
+ '-sALLOW_MEMORY_GROWTH=1', '-sABORTING_MALLOC=0', '-sMAXIMUM_MEMORY=2147483648',
+ '-sSTACK_SIZE=1048576', '-sFILESYSTEM=0',
+ '-sEXPORTED_FUNCTIONS=["_foundry_enumerate","_foundry_enumerate_graph","_malloc","_free"]',
+ '-sEXPORTED_RUNTIME_METHODS=["ccall","HEAPU8","HEAPU32"]',
+]
+
+
+def sha256(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--emcc', default='emcc')
+ parser.add_argument('--release', action='store_true', help='Require a clean checkout; use the release version.')
+ args = parser.parse_args()
+ compiler = subprocess.check_output([args.emcc, '--version'], text=True).splitlines()[0]
+ if args.release and not re.search(r'\b4\.0\.23\b', compiler):
+ parser.error('Release builds require Emscripten 4.0.23.')
+ revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
+ dirty = bool(subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=normal'], cwd=ROOT))
+ if args.release and dirty:
+ parser.error('A release requires a clean committed checkout.')
+ inputs = sorted(set(SOURCES + [
+ str(p.relative_to(ROOT)) for pattern in ('include/**/*.h', 'src/solver/*.h', 'packages/solver-web/*')
+ for p in ROOT.glob(pattern) if p.is_file()
+ ] + ['tools/build_solver_web.py', 'LICENSE']))
+ source_files = {name: sha256(ROOT / name) for name in inputs}
+ source_hash = hashlib.sha256(json.dumps(source_files, sort_keys=True).encode()).hexdigest()
+ descriptor = json.loads((PACKAGE / 'package.json').read_text())
+ version = descriptor['version'] if args.release else descriptor['version'] + '-dev.' + source_hash[:12]
+ if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-dev\.[0-9a-f]+)?', version):
+ parser.error('Invalid package version.')
+ build_root = ROOT / 'build/solver-web'
+ output = build_root / ('tiling-foundry-solver-web-' + version)
+ output.mkdir(parents=True, exist_ok=True)
+ subprocess.run([args.emcc, *FLAGS, *SOURCES, '-o', str(output / 'foundry.mjs')], cwd=ROOT, check=True)
+ for name in ('client.mjs', 'worker.mjs', 'prepare.mjs', 'README.md'):
+ shutil.copyfile(PACKAGE / name, output / name)
+ shutil.copyfile(ROOT / 'LICENSE', output / 'LICENSE')
+ descriptor['version'] = version
+ (output / 'package.json').write_text(json.dumps(descriptor, indent=2) + '\n')
+ artifacts = ['foundry.mjs', 'foundry.wasm', 'client.mjs', 'worker.mjs', 'prepare.mjs',
+ 'README.md', 'LICENSE', 'package.json']
+ manifest = dict(
+ name=descriptor['name'], version=version, api_version=1, solvers=['optimized', 'reference'],
+ wasm_abi_version=3, runtime='wasm', geometries=['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'],
+ source=dict(repository='https://github.com/xtraid/tiling-foundry', revision=revision,
+ dirty=dirty, sha256=source_hash, files=source_files),
+ build=dict(compiler=compiler, flags=FLAGS),
+ limits=dict(max_tiles=None, max_used_colors=65536, max_cells=65536, max_area=None,
+ max_palette=65536, max_memory_bytes=2147483648, default_timeout_seconds=600,
+ default_idle_timeout_seconds=0),
+ files={name: dict(sha256=sha256(output / name), size=(output / name).stat().st_size) for name in artifacts},
+ )
+ (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
+ archive = build_root / (output.name + '.tar.gz')
+ # Normalize gzip and tar metadata so the same revision/toolchain produces
+ # the same archive regardless of build time, checkout path or file owner.
+ with archive.open('wb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as compressed:
+ with tarfile.open(fileobj=compressed, mode='w', format=tarfile.USTAR_FORMAT) as tar:
+ for name in artifacts + ['manifest.json']:
+ info = tarfile.TarInfo('package/' + name)
+ info.size = (output / name).stat().st_size
+ info.mode = 0o644
+ with (output / name).open('rb') as source:
+ tar.addfile(info, source)
+ digest = sha256(archive)
+ archive.with_name(archive.name + '.sha256').write_text(f'{digest} {archive.name}\n')
+ print(json.dumps(dict(package=str(output), archive=str(archive), sha256=digest, version=version), indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/check_generated_pages.py b/tools/check_generated_pages.py
index 97a656b..0531a15 100644
--- a/tools/check_generated_pages.py
+++ b/tools/check_generated_pages.py
@@ -380,6 +380,10 @@ def _local_target(
elif baseurl and path.startswith(f"{baseurl}/"):
path = path[len(baseurl) :]
elif baseurl:
+ # Absolute URLs can intentionally link to another project on this host.
+ # Relative links must still stay inside this project base URL.
+ if urlsplit(value).netloc:
+ return None
raise ValueError(f"internal URL escapes baseurl {baseurl!r}")
trailing_slash = path.endswith("/")