Add aspirin 15-puzzle and auto-solver apps for Ontrol Orion
- gen_aspirin.py generates aspirin.sax (playable) and aspirin_solver.sax (30-move shuffle, then one inverse move per second) from stock kits - build.py compiles with sedonac and packages simulator backup zips - verify_logic.py models the Sedona scan engine to test both apps offline - viewer/OxViewer.java shows the headless simulator's screen live over SOX Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
run/
|
||||||
|
viewer/*.class
|
||||||
|
viewer/*.log
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# aspirin
|
||||||
|
|
||||||
|
15-puzzle (sliding 1-15) for the Ontrol **Orion** (800x480), built only from stock Sedona kits
|
||||||
|
(`control`, `ontrolControl`, `ontrolOx`, `ontrolOrion`), so it runs on the stock simulator.
|
||||||
|
|
||||||
|
| App | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `aspirin.sax` / `app-ORION-aspirin.zip` | Playable game: auto-shuffle at start, tap a tile next to the gap to slide it, SHUFFLE button, move counter, SOLVED! |
|
||||||
|
| `aspirin_solver.sax` / `app-ORION-aspirin_solver.zip` | Demo: shuffles 30 moves, then an auto solver plays one move per second until SOLVED! (it records the shuffle and replays the inverse moves) |
|
||||||
|
|
||||||
|
## Run in the simulator
|
||||||
|
|
||||||
|
`simulatorLauncher.exe` → folder button → pick a `app-ORION-*.zip` → Start Simulator.
|
||||||
|
The simulator is headless; to watch the screen run `viewer\run_viewer.cmd`
|
||||||
|
(live view over SOX as `guest`, display only).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python build.py # gen .sax -> sedonac .sab -> launcher backup zips (both apps)
|
||||||
|
python verify_logic.py # offline scan-engine model test of the game
|
||||||
|
python verify_logic.py --solver
|
||||||
|
```
|
||||||
|
|
||||||
|
`gen_aspirin.py` holds layout, colours, shuffle length (`SHUFFLE_MOVES`) and solver delay
|
||||||
|
(`SOLVE_DELAY`). Paths to the Sedona install are at the top of `build.py`.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- Each tile is a `PushButtonLabel` whose `x`/`y` are linked from logic, so tiles move.
|
||||||
|
- Tile state = offset from home cell held in `ADemux2` latches; blank cell = 3 - sum(offsets).
|
||||||
|
- A tile moves on a one-scan `OneShot` when it is next to the blank and touched (or picked
|
||||||
|
by the shuffle); it latches the blank's cell and the blank follows automatically.
|
||||||
|
- Shuffle uses a logistic-map RNG made of `Reset`/`Mul2` blocks; only legal moves, so always solvable.
|
||||||
|
- Solved = blank home and the weighted sum of tile offsets is 0 (rearrangement inequality).
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+2154
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+2483
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
"""Builds aspirin(.sax/_solver.sax) -> .sab and packages app-ORION-<name>.zip for simulatorLauncher.exe.
|
||||||
|
|
||||||
|
Zip layout matches the launcher's ExampleApp backups:
|
||||||
|
app.sax, device/app.sab, device/kits.scode, device/fonts/*.oxfl, fontBankXL/*.oxfl
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
SEDONA = r"C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona"
|
||||||
|
SIM = os.path.join(SEDONA, "ontrol", "ontrolSimulator")
|
||||||
|
EXAMPLE = os.path.join(SIM, "ExampleApp", "app-ORION-VM_miniini.zip") # source of Orion kits.scode
|
||||||
|
FONTS = os.path.join(SEDONA, "ontrol", "fontBankXL")
|
||||||
|
USED_FONTS = ["18pt Tahoma.oxfl", "24pt Tahoma.oxfl", "32pt Tahoma.oxfl",
|
||||||
|
"bold 32pt Tahoma.oxfl", "bold 36pt Tahoma.oxfl", "bold 48pt Tahoma.oxfl",
|
||||||
|
"bold 52pt Tahoma.oxfl"]
|
||||||
|
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
os.chdir(here)
|
||||||
|
env = dict(os.environ, sedona_home=SEDONA)
|
||||||
|
with zipfile.ZipFile(EXAMPLE) as src:
|
||||||
|
scode = src.read("device/kits.scode")
|
||||||
|
|
||||||
|
for name, args in (("aspirin", []), ("aspirin_solver", ["--solver"])):
|
||||||
|
subprocess.check_call([sys.executable, "gen_aspirin.py"] + args)
|
||||||
|
subprocess.check_call([os.path.join(SEDONA, "bin", "sedonac.exe"), f"{name}.sax"], env=env)
|
||||||
|
out = f"app-ORION-{name}.zip"
|
||||||
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
z.write(f"{name}.sax", "app.sax")
|
||||||
|
z.write(f"{name}.sab", "device/app.sab")
|
||||||
|
z.writestr("device/kits.scode", scode)
|
||||||
|
for f in USED_FONTS:
|
||||||
|
p = os.path.join(FONTS, f)
|
||||||
|
z.write(p, f"device/fonts/{f}")
|
||||||
|
z.write(p, f"fontBankXL/{f}")
|
||||||
|
print("wrote", out)
|
||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
"""Generates aspirin.sax - a 4x4 sliding (15) puzzle for the Ontrol Orion (800x480).
|
||||||
|
|
||||||
|
Built only from stock kits (control, ontrolControl, ontrolOx, ontrolOrion), so it runs
|
||||||
|
on the stock simulator scode.
|
||||||
|
|
||||||
|
How it works
|
||||||
|
- Each tile t is a PushButtonLabel whose x/y are linked from logic, so tiles move on screen.
|
||||||
|
- Tile state = offset from its home cell (du, dv), held in ADemux2.out2 (starts at 0 = solved).
|
||||||
|
- Blank cell: cb = 3 - sum(du), rb = 3 - sum(dv) (cells form a permutation).
|
||||||
|
- Tile is movable when (col-cb)^2 + (row-rb)^2 == 1. A rising edge of (movable AND
|
||||||
|
(touched OR shuffle-pick)) fires a one-scan OneShot that latches the blank's cell into
|
||||||
|
the tile; the blank then becomes the tile's old cell automatically.
|
||||||
|
- Shuffle: a logistic-map RNG (free running every scan) picks a direction code
|
||||||
|
2*dc+dr in {-2,-1,1,2}; the tile whose code matches moves. Runs for a few seconds,
|
||||||
|
at startup and whenever SHUFFLE is pressed. Only legal moves => always solvable.
|
||||||
|
- Solved check: blank home AND sum_t t*(linear offset_t) == 0 (rearrangement inequality).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --solver: demo variant (aspirin_solver.sax). Shuffle makes exactly SHUFFLE_MOVES moves
|
||||||
|
# (no immediate back-steps) and pushes each move's code on a stack; after a pause the
|
||||||
|
# auto solver pops the stack and plays the inverse move every SOLVE_PERIOD seconds.
|
||||||
|
# No stock function block can search for a solution, so the solver undoes the shuffle.
|
||||||
|
SOLVER = "--solver" in sys.argv
|
||||||
|
OUT = "aspirin_solver.sax" if SOLVER else "aspirin.sax"
|
||||||
|
SHUFFLE_MOVES = 30
|
||||||
|
STACK = 32
|
||||||
|
SOLVE_DELAY = 2.0
|
||||||
|
|
||||||
|
N = 4
|
||||||
|
PITCH = 105 # tile pitch in px
|
||||||
|
TILE = 100 # tile size in px
|
||||||
|
X0, Y0 = 40, 40 # top-left tile
|
||||||
|
SHUFFLE_SECS = 10.0
|
||||||
|
|
||||||
|
# RGB565 colours
|
||||||
|
C_PAGE = 0x10A2
|
||||||
|
C_BOARD = 0x31A6
|
||||||
|
C_TILE = 0xFFFF
|
||||||
|
C_TILE_TXT = 0xC000 # dark red
|
||||||
|
C_WHITE = 0xFFFF
|
||||||
|
C_RED = 0xD800
|
||||||
|
C_GREEN = 0x07E0
|
||||||
|
C_GREY = 0xAD55
|
||||||
|
|
||||||
|
comps = [] # xml lines
|
||||||
|
links = []
|
||||||
|
_id = [0]
|
||||||
|
|
||||||
|
|
||||||
|
def nid():
|
||||||
|
_id[0] += 1
|
||||||
|
return _id[0]
|
||||||
|
|
||||||
|
|
||||||
|
def buf(s):
|
||||||
|
return base64.b64encode(s.encode("ascii") + b"\0").decode()
|
||||||
|
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
def __init__(self, name, typ, props=None, path=None):
|
||||||
|
assert len(name) <= 7, name
|
||||||
|
self.name, self.typ, self.props = name, typ, props or {}
|
||||||
|
self.kids = []
|
||||||
|
self.path = path
|
||||||
|
self.id = None
|
||||||
|
|
||||||
|
def add(self, name, typ, **props):
|
||||||
|
n = Node(name, typ, props, f"{self.path}/{name}")
|
||||||
|
self.kids.append(n)
|
||||||
|
return n
|
||||||
|
|
||||||
|
def emit(self, ind):
|
||||||
|
self.id = nid()
|
||||||
|
pad = " " * ind
|
||||||
|
out = [f'{pad}<comp name="{self.name}" id="{self.id}" type="{self.typ}">']
|
||||||
|
for k, v in self.props.items():
|
||||||
|
if isinstance(v, bool):
|
||||||
|
v = "true" if v else "false"
|
||||||
|
elif isinstance(v, float):
|
||||||
|
v = repr(round(v, 6))
|
||||||
|
out.append(f'{pad} <prop name="{k}" val="{v}"/>')
|
||||||
|
for kid in self.kids:
|
||||||
|
out += kid.emit(ind + 1)
|
||||||
|
out.append(f"{pad}</comp>")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def link(a, aslot, b, bslot):
|
||||||
|
links.append(f' <link from="{a.path}.{aslot}" to="{b.path}.{bslot}"/>')
|
||||||
|
|
||||||
|
|
||||||
|
def root(name, typ, **props):
|
||||||
|
return Node(name, typ, props, f"/{name}")
|
||||||
|
|
||||||
|
|
||||||
|
def reset(parent, name, in_min, in_max, out_min, out_max):
|
||||||
|
"""Affine map (clamped) using control::Reset - lets constants live in config props."""
|
||||||
|
return parent.add(name, "control::Reset", inMin=float(in_min), inMax=float(in_max),
|
||||||
|
outMin=float(out_min), outMax=float(out_max))
|
||||||
|
|
||||||
|
|
||||||
|
def sum_tree(parent, prefix, sources, slot):
|
||||||
|
"""Sum of up to 16 float sources with Add4 blocks; returns the final Add4."""
|
||||||
|
groups = [sources[i:i + 4] for i in range(0, len(sources), 4)]
|
||||||
|
partial = []
|
||||||
|
for gi, g in enumerate(groups):
|
||||||
|
a = parent.add(f"{prefix}{gi}", "control::Add4")
|
||||||
|
for k, src in enumerate(g):
|
||||||
|
link(src, slot, a, f"in{k + 1}")
|
||||||
|
partial.append(a)
|
||||||
|
tot = parent.add(f"{prefix}T", "control::Add4")
|
||||||
|
for k, p in enumerate(partial):
|
||||||
|
link(p, "out", tot, f"in{k + 1}")
|
||||||
|
return tot
|
||||||
|
|
||||||
|
|
||||||
|
def or_tree(parent, prefix, sources, slot):
|
||||||
|
groups = [sources[i:i + 4] for i in range(0, len(sources), 4)]
|
||||||
|
partial = []
|
||||||
|
for gi, g in enumerate(groups):
|
||||||
|
o = parent.add(f"{prefix}{gi}", "control::Or4")
|
||||||
|
for k, src in enumerate(g):
|
||||||
|
link(src, slot, o, f"in{k + 1}")
|
||||||
|
partial.append(o)
|
||||||
|
tot = parent.add(f"{prefix}T", "control::Or4")
|
||||||
|
for k, p in enumerate(partial):
|
||||||
|
link(p, "out", tot, f"in{k + 1}")
|
||||||
|
return tot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- service
|
||||||
|
service = root("service", "sys::Folder")
|
||||||
|
plat = service.add("OrionPl", "ontrolOrion::OrionPlatformService")
|
||||||
|
oxbase = plat.add("OxBase", "ontrolOx::OxBase", userTimeout=255, lcdSleepTimeout=60,
|
||||||
|
lcdSleepBrightness=30)
|
||||||
|
users = service.add("users", "sys::UserService")
|
||||||
|
users.add("admin", "sys::User", cred="wHy/iCHUdXWkccFgala3nl9uamg=", perm=2147483647, prov=255)
|
||||||
|
users.add("guest", "sys::User", cred="jVShQjDWIkrr+o3lYLcAPPvNVio=", perm=63)
|
||||||
|
service.add("sox", "sox::SoxService")
|
||||||
|
driver = root("driver", "sys::Folder")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ox (screen)
|
||||||
|
ox = root("ox", "sys::Folder")
|
||||||
|
page = ox.add("Game", "ontrolOx::OxPage", backgroundColor=C_PAGE, touchTimeout=0)
|
||||||
|
page.add("Board", "ontrolOx::Geometry", x=X0 - 5, y=Y0 - 5,
|
||||||
|
width=N * PITCH + 5, height=N * PITCH + 5, backgroundColor=C_BOARD)
|
||||||
|
tiles = {}
|
||||||
|
for t in range(1, 16):
|
||||||
|
hc, hr = (t - 1) % N, (t - 1) // N
|
||||||
|
tiles[t] = page.add(f"Tile{t:02d}", "ontrolOx::PushButtonLabel",
|
||||||
|
x=X0 + hc * PITCH, y=Y0 + hr * PITCH, width=TILE, height=TILE,
|
||||||
|
fontSize=48, fontAttributes=1, attributes=24,
|
||||||
|
text=buf(str(t)), textColor=C_TILE_TXT, backgroundColor=C_TILE)
|
||||||
|
RX, RW = 490, 290
|
||||||
|
page.add("Title", "ontrolOx::PushButtonLabel", x=RX, y=40, width=RW, height=70,
|
||||||
|
fontSize=52, fontAttributes=1, attributes=24, text=buf("ASPIRIN"),
|
||||||
|
textColor=C_RED, backgroundColor=C_PAGE)
|
||||||
|
page.add("Sub", "ontrolOx::PushButtonLabel", x=RX, y=110, width=RW, height=34,
|
||||||
|
fontSize=18, attributes=24, text=buf("AUTO SOLVER" if SOLVER else "15 PUZZLE"),
|
||||||
|
textColor=C_GREY, backgroundColor=C_PAGE)
|
||||||
|
moves_lbl = page.add("Moves", "ontrolOx::NumericLabel", x=RX, y=160, width=RW, height=50,
|
||||||
|
fontSize=32, attributes=24, text=buf("Moves: %.0f"),
|
||||||
|
textColor=C_WHITE, backgroundColor=C_PAGE)
|
||||||
|
shuf_btn = page.add("Shuffle", "ontrolOx::PushButtonLabel", x=RX + 30, y=225, width=RW - 60,
|
||||||
|
height=70, fontSize=32, fontAttributes=1, attributes=24,
|
||||||
|
text=buf("SHUFFLE"), textColor=C_WHITE, backgroundColor=C_RED)
|
||||||
|
busy_lbl = page.add("Busy", "ontrolOx::BooleanLabel", x=RX, y=310, width=RW, height=36,
|
||||||
|
fontSize=24, attributes=24,
|
||||||
|
trueText=buf("Shuffling..."), falseText=buf(" "),
|
||||||
|
trueTextColor=C_GREY, falseTextColor=C_PAGE,
|
||||||
|
trueBackgroundColor=C_PAGE, falseBackgroundColor=C_PAGE)
|
||||||
|
if SOLVER:
|
||||||
|
solving_lbl = page.add("Solving", "ontrolOx::BooleanLabel", x=RX, y=350, width=RW,
|
||||||
|
height=36, fontSize=24, attributes=24,
|
||||||
|
trueText=buf("Auto-solving..."), falseText=buf(" "),
|
||||||
|
trueTextColor=C_WHITE, falseTextColor=C_PAGE,
|
||||||
|
trueBackgroundColor=C_PAGE, falseBackgroundColor=C_PAGE)
|
||||||
|
solved_lbl = page.add("Solved", "ontrolOx::BooleanLabel", x=RX, y=395, width=RW, height=60,
|
||||||
|
fontSize=36, fontAttributes=1, attributes=24,
|
||||||
|
trueText=buf("SOLVED!"), falseText=buf(" "),
|
||||||
|
trueTextColor=C_GREEN, falseTextColor=C_PAGE,
|
||||||
|
trueBackgroundColor=C_PAGE, falseBackgroundColor=C_PAGE)
|
||||||
|
link(page, "pageId", oxbase, "homePageId")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- logic
|
||||||
|
logic = root("logic", "sys::Folder")
|
||||||
|
g = logic.add("glob", "sys::Folder") # executes before tile folders
|
||||||
|
|
||||||
|
true_c = g.add("TRUE", "control::ConstBool", out=True)
|
||||||
|
|
||||||
|
# shuffle control: startup pulse OR button -> OneShot(SHUFFLE_SECS)
|
||||||
|
boot = g.add("Boot", "control::OneShot", pulseWidth=0.5)
|
||||||
|
link(true_c, "out", boot, "in")
|
||||||
|
shreq = g.add("ShReq", "control::Or2")
|
||||||
|
link(boot, "out", shreq, "in2")
|
||||||
|
if SOLVER:
|
||||||
|
# SHUFFLE only accepted when the stack is empty (puzzle solved / idle)
|
||||||
|
btn_ok = g.add("BtnOk", "control::And2")
|
||||||
|
link(shuf_btn, "touch", btn_ok, "in1")
|
||||||
|
link(btn_ok, "out", shreq, "in1")
|
||||||
|
shreq_p = g.add("ShReqP", "control::OneShot", pulseWidth=0.05) # one-scan request
|
||||||
|
link(shreq, "out", shreq_p, "in")
|
||||||
|
shuf = g.add("Shuf", "control::SRLatch") # set by request, reset after SHUFFLE_MOVES
|
||||||
|
link(shreq_p, "out", shuf, "s")
|
||||||
|
else:
|
||||||
|
link(shuf_btn, "touch", shreq, "in1")
|
||||||
|
shuf = g.add("Shuf", "control::OneShot", pulseWidth=SHUFFLE_SECS)
|
||||||
|
link(shreq, "out", shuf, "in")
|
||||||
|
link(shuf, "out", busy_lbl, "in")
|
||||||
|
|
||||||
|
# RNG: x' = 0.003 + 0.9945*4*x*(1-x) (logistic map, never hits 0)
|
||||||
|
inv = reset(g, "RInv", 0, 1, 1, 0) # 1 - x
|
||||||
|
mul = g.add("RMul", "control::Mul2") # x(1-x)
|
||||||
|
rx = reset(g, "RX", 0, 0.25, 0.003, 0.9975) # next x
|
||||||
|
link(rx, "out", inv, "in")
|
||||||
|
link(rx, "out", mul, "in1")
|
||||||
|
link(inv, "out", mul, "in2")
|
||||||
|
link(mul, "out", rx, "in")
|
||||||
|
# x -> direction code {-2,-1,1,2}; breakpoints at arcsine quartiles for ~uniform picks
|
||||||
|
code = g.add("Code", "control::Linearize",
|
||||||
|
x0=0.0, y0=-2.0, x1=0.146, y1=-2.0, x2=0.147, y2=-1.0, x3=0.5, y3=-1.0,
|
||||||
|
x4=0.501, y4=1.0, x5=0.853, y5=1.0, x6=0.854, y6=2.0, x7=1.0, y7=2.0)
|
||||||
|
link(rx, "out", code, "in")
|
||||||
|
|
||||||
|
if SOLVER:
|
||||||
|
# sel = code the tiles compare against: random code while shuffling, else inverse of stack top
|
||||||
|
neg_top = reset(g, "NegTop", -2, 2, 2, -2) # -top (linked from stack below)
|
||||||
|
bt = g.add("BT", "control::Cmpr") # random pick would undo last move
|
||||||
|
link(code, "out", bt, "x")
|
||||||
|
link(neg_top, "out", bt, "y")
|
||||||
|
not_bt = g.add("NotBT", "control::Not")
|
||||||
|
link(bt, "xey", not_bt, "in")
|
||||||
|
# no move in the scan right after a move, so every move is a separate edge for counting
|
||||||
|
not_am = g.add("NotAM", "control::Not") # in <- any-move (post, prev scan)
|
||||||
|
sh_en = g.add("ShEn", "control::And4")
|
||||||
|
link(shuf, "out", sh_en, "in1")
|
||||||
|
link(not_bt, "out", sh_en, "in2")
|
||||||
|
link(not_am, "out", sh_en, "in3")
|
||||||
|
not_rq = g.add("NotRq", "control::Not") # no move while the counter resets
|
||||||
|
link(shreq_p, "out", not_rq, "in")
|
||||||
|
link(not_rq, "out", sh_en, "in4")
|
||||||
|
tick = g.add("Tick", "control::TickTock", ticksPerSec=1)
|
||||||
|
step_os = g.add("Step", "control::OneShot", pulseWidth=0.05) # one scan per second
|
||||||
|
link(tick, "out", step_os, "in")
|
||||||
|
sv_en = g.add("SvEn", "control::And2") # in1 <- solving (post, prev scan)
|
||||||
|
link(step_os, "out", sv_en, "in2")
|
||||||
|
en = g.add("En", "control::Or2")
|
||||||
|
link(sh_en, "out", en, "in1")
|
||||||
|
link(sv_en, "out", en, "in2")
|
||||||
|
sel = g.add("Sel", "control::ASW")
|
||||||
|
link(neg_top, "out", sel, "in1")
|
||||||
|
link(code, "out", sel, "in2")
|
||||||
|
link(shuf, "out", sel, "s1")
|
||||||
|
pick, pick_slot, enable = sel, "out", en
|
||||||
|
else:
|
||||||
|
pick, pick_slot, enable = code, "out", shuf
|
||||||
|
|
||||||
|
# tile folders are created in reverse order (15..1) so the suffix-sum chain settles in one scan
|
||||||
|
tf = {t: logic.add(f"t{t:02d}", "sys::Folder") for t in range(15, 0, -1)}
|
||||||
|
post = logic.add("post", "sys::Folder")
|
||||||
|
|
||||||
|
# tile components are built first so the global sums (in glob) can link to their holds
|
||||||
|
tile_parts = {}
|
||||||
|
for t in range(15, 0, -1):
|
||||||
|
f = tf[t]
|
||||||
|
hc, hr = (t - 1) % N, (t - 1) // N
|
||||||
|
p = {}
|
||||||
|
p["ru"] = reset(f, "Ru", 0, 3, -hc, 3 - hc) # target du = cb - hc
|
||||||
|
p["rv"] = reset(f, "Rv", 0, 3, -hr, 3 - hr) # target dv = rb - hr
|
||||||
|
p["dc"] = f.add("Dc", "control::Sub2") # col - cb
|
||||||
|
p["dr"] = f.add("Dr", "control::Sub2") # row - rb
|
||||||
|
p["sc"] = f.add("Sc", "control::Mul2")
|
||||||
|
p["sr"] = f.add("Sr", "control::Mul2")
|
||||||
|
p["d2"] = f.add("D2", "control::Add2")
|
||||||
|
p["val"] = f.add("Val", "ontrolControl::InRange", lowLimit=0.5, highLimit=1.5)
|
||||||
|
p["k"] = f.add("K", "control::Add4") # 2*dc + dr
|
||||||
|
p["m"] = f.add("M", "control::Cmpr")
|
||||||
|
p["h"] = f.add("H", "control::And2")
|
||||||
|
if not SOLVER:
|
||||||
|
p["p"] = f.add("P", "control::Or2")
|
||||||
|
p["g"] = f.add("G", "control::And2")
|
||||||
|
p["os"] = f.add("OS", "control::OneShot", pulseWidth=0.03)
|
||||||
|
p["du"] = f.add("Du", "control::ADemux2")
|
||||||
|
p["dv"] = f.add("Dv", "control::ADemux2")
|
||||||
|
p["xr"] = reset(f, "Xr", -3, 3, X0 + (hc - 3) * PITCH, X0 + (hc + 3) * PITCH)
|
||||||
|
p["yr"] = reset(f, "Yr", -3, 3, Y0 + (hr - 3) * PITCH, Y0 + (hr + 3) * PITCH)
|
||||||
|
p["xi"] = f.add("Xi", "control::F2I")
|
||||||
|
p["yi"] = f.add("Yi", "control::F2I")
|
||||||
|
p["q4"] = reset(f, "Q4", -3, 3, -12, 12) # 4*dv
|
||||||
|
p["t"] = f.add("T", "control::Add4") # suffix sum of (du + 4dv)
|
||||||
|
tile_parts[t] = p
|
||||||
|
|
||||||
|
# global blank position (in glob folder, uses previous-scan holds)
|
||||||
|
su = sum_tree(g, "SU", [tile_parts[t]["du"] for t in range(1, 16)], "out2")
|
||||||
|
sv = sum_tree(g, "SV", [tile_parts[t]["dv"] for t in range(1, 16)], "out2")
|
||||||
|
cb = reset(g, "CB", -30, 30, 33, -27) # 3 - sum(du)
|
||||||
|
rb = reset(g, "RB", -30, 30, 33, -27) # 3 - sum(dv)
|
||||||
|
link(su, "out", cb, "in")
|
||||||
|
link(sv, "out", rb, "in")
|
||||||
|
|
||||||
|
for t in range(15, 0, -1):
|
||||||
|
p = tile_parts[t]
|
||||||
|
tile = tiles[t]
|
||||||
|
link(cb, "out", p["ru"], "in")
|
||||||
|
link(rb, "out", p["rv"], "in")
|
||||||
|
link(p["du"], "out2", p["dc"], "in1")
|
||||||
|
link(p["ru"], "out", p["dc"], "in2")
|
||||||
|
link(p["dv"], "out2", p["dr"], "in1")
|
||||||
|
link(p["rv"], "out", p["dr"], "in2")
|
||||||
|
link(p["dc"], "out", p["sc"], "in1")
|
||||||
|
link(p["dc"], "out", p["sc"], "in2")
|
||||||
|
link(p["dr"], "out", p["sr"], "in1")
|
||||||
|
link(p["dr"], "out", p["sr"], "in2")
|
||||||
|
link(p["sc"], "out", p["d2"], "in1")
|
||||||
|
link(p["sr"], "out", p["d2"], "in2")
|
||||||
|
link(p["d2"], "out", p["val"], "in")
|
||||||
|
link(p["dc"], "out", p["k"], "in1")
|
||||||
|
link(p["dc"], "out", p["k"], "in2")
|
||||||
|
link(p["dr"], "out", p["k"], "in3")
|
||||||
|
link(p["k"], "out", p["m"], "x")
|
||||||
|
link(pick, pick_slot, p["m"], "y")
|
||||||
|
link(p["m"], "xey", p["h"], "in1")
|
||||||
|
link(enable, "out", p["h"], "in2")
|
||||||
|
link(p["val"], "out", p["g"], "in1")
|
||||||
|
if SOLVER: # demo: tiles are driven by the solver only
|
||||||
|
link(p["h"], "out", p["g"], "in2")
|
||||||
|
else:
|
||||||
|
link(tile, "touch", p["p"], "in1")
|
||||||
|
link(p["h"], "out", p["p"], "in2")
|
||||||
|
link(p["p"], "out", p["g"], "in2")
|
||||||
|
link(p["g"], "out", p["os"], "in")
|
||||||
|
link(p["ru"], "out", p["du"], "in")
|
||||||
|
link(p["os"], "out", p["du"], "s1")
|
||||||
|
link(p["rv"], "out", p["dv"], "in")
|
||||||
|
link(p["os"], "out", p["dv"], "s1")
|
||||||
|
link(p["du"], "out2", p["xr"], "in")
|
||||||
|
link(p["dv"], "out2", p["yr"], "in")
|
||||||
|
link(p["xr"], "out", p["xi"], "in")
|
||||||
|
link(p["yr"], "out", p["yi"], "in")
|
||||||
|
link(p["xi"], "out", tile, "x")
|
||||||
|
link(p["yi"], "out", tile, "y")
|
||||||
|
link(p["dv"], "out2", p["q4"], "in")
|
||||||
|
link(p["du"], "out2", p["t"], "in1")
|
||||||
|
link(p["q4"], "out", p["t"], "in2")
|
||||||
|
if t < 15:
|
||||||
|
link(tile_parts[t + 1]["t"], "out", p["t"], "in3")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- post: win check + move counter
|
||||||
|
w = sum_tree(post, "W", [tile_parts[t]["t"] for t in range(1, 16)], "out")
|
||||||
|
w_ok = post.add("WOk", "ontrolControl::InRange", lowLimit=-0.5, highLimit=0.5)
|
||||||
|
link(w, "out", w_ok, "in")
|
||||||
|
bsum = post.add("BSum", "control::Add2")
|
||||||
|
link(cb, "out", bsum, "in1")
|
||||||
|
link(rb, "out", bsum, "in2")
|
||||||
|
b_ok = post.add("BOk", "ontrolControl::InRange", lowLimit=5.5, highLimit=6.5)
|
||||||
|
link(bsum, "out", b_ok, "in")
|
||||||
|
not_sh = post.add("NotSh", "control::Not")
|
||||||
|
link(shuf, "out", not_sh, "in")
|
||||||
|
solved = post.add("Solved", "control::And4")
|
||||||
|
link(w_ok, "out", solved, "in1")
|
||||||
|
link(b_ok, "out", solved, "in2")
|
||||||
|
link(not_sh, "out", solved, "in3")
|
||||||
|
link(true_c, "out", solved, "in4")
|
||||||
|
link(solved, "out", solved_lbl, "in")
|
||||||
|
|
||||||
|
anym = or_tree(post, "AM", [tile_parts[t]["os"] for t in range(1, 16)], "out")
|
||||||
|
cnt = post.add("Cnt", "control::Count")
|
||||||
|
link(anym, "out", cnt, "in")
|
||||||
|
link(true_c, "out", cnt, "enable")
|
||||||
|
link(shuf, "out", cnt, "r")
|
||||||
|
cntf = post.add("CntF", "control::I2F")
|
||||||
|
link(cnt, "out", cntf, "in")
|
||||||
|
link(cntf, "out", moves_lbl, "in")
|
||||||
|
|
||||||
|
if SOLVER:
|
||||||
|
# stop the shuffle after SHUFFLE_MOVES moves
|
||||||
|
sh_cnt = post.add("ShCnt", "control::Count")
|
||||||
|
link(anym, "out", sh_cnt, "in")
|
||||||
|
link(true_c, "out", sh_cnt, "enable")
|
||||||
|
link(shreq_p, "out", sh_cnt, "r")
|
||||||
|
link(anym, "out", not_am, "in")
|
||||||
|
sh_cntf = post.add("ShCntF", "control::I2F")
|
||||||
|
link(sh_cnt, "out", sh_cntf, "in")
|
||||||
|
sh_done = post.add("ShDone", "ontrolControl::InRange",
|
||||||
|
lowLimit=SHUFFLE_MOVES - 0.5, highLimit=10000.0)
|
||||||
|
link(sh_cntf, "out", sh_done, "in")
|
||||||
|
link(sh_done, "out", shuf, "r")
|
||||||
|
# solving starts SOLVE_DELAY s after the shuffle ends
|
||||||
|
dly = post.add("Dly", "control::DlyOn", delayTime=SOLVE_DELAY)
|
||||||
|
link(not_sh, "out", dly, "in")
|
||||||
|
link(dly, "out", sv_en, "in1")
|
||||||
|
not_sv = post.add("NotSv", "control::Not")
|
||||||
|
link(solved, "out", not_sv, "in")
|
||||||
|
sv_lbl = post.add("SvLbl", "control::And2")
|
||||||
|
link(dly, "out", sv_lbl, "in1")
|
||||||
|
link(not_sv, "out", sv_lbl, "in2")
|
||||||
|
link(sv_lbl, "out", solving_lbl, "in")
|
||||||
|
|
||||||
|
# move stack: r1 = top. Selectors all run before the registers, so every register
|
||||||
|
# latches its neighbour's previous value -> push shifts down, pop shifts up.
|
||||||
|
stk_s = logic.add("stkSel", "sys::Folder")
|
||||||
|
stk_r = logic.add("stkReg", "sys::Folder")
|
||||||
|
sels = [stk_s.add(f"S{i:02d}", "control::ASW") for i in range(1, STACK + 1)]
|
||||||
|
regs = [stk_r.add(f"R{i:02d}", "control::ADemux2") for i in range(1, STACK + 1)]
|
||||||
|
for i in range(STACK):
|
||||||
|
link(shuf, "out", sels[i], "s1") # true = push, false = pop
|
||||||
|
if i == 0:
|
||||||
|
link(sel, "out", sels[i], "in2") # push the executed code
|
||||||
|
else:
|
||||||
|
link(regs[i - 1], "out2", sels[i], "in2")
|
||||||
|
if i + 1 < STACK:
|
||||||
|
link(regs[i + 1], "out2", sels[i], "in1") # pop (last slot pulls in 0)
|
||||||
|
link(sels[i], "out", regs[i], "in")
|
||||||
|
link(anym, "out", regs[i], "s1") # latch on every executed move
|
||||||
|
empty = stk_r.add("Empty", "ontrolControl::InRange", lowLimit=-0.5, highLimit=0.5)
|
||||||
|
link(regs[0], "out2", empty, "in")
|
||||||
|
link(empty, "out", btn_ok, "in2")
|
||||||
|
link(regs[0], "out2", neg_top, "in")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- write
|
||||||
|
body = []
|
||||||
|
for r in (service, driver, logic, ox):
|
||||||
|
body += r.emit(1)
|
||||||
|
|
||||||
|
# checksums pinned to the kits inside the Orion simulator's kits.scode
|
||||||
|
kits = {"sys": "d3984c51", "control": "808b7db3", "inet": "25648ba7",
|
||||||
|
"ontrolControl": "254170a5", "ontrolOrion": "ebaa6c82", "ontrolOx": "641b8633",
|
||||||
|
"ontrolPlatform": "5c12ff9d", "sox": "397a84dd"}
|
||||||
|
xml = ["<?xml version='1.0'?>", "<sedonaApp>", "<schema>"]
|
||||||
|
xml += [f' <kit name="{k}" checksum="{c}" />' for k, c in kits.items()]
|
||||||
|
xml += ["</schema>", "<app>",
|
||||||
|
' <prop name="deviceName" val="ORION"/>',
|
||||||
|
f' <prop name="appName" val="{OUT[:-4]}"/>',
|
||||||
|
' <prop name="scanPeriod" val="100"/>']
|
||||||
|
xml += body
|
||||||
|
xml += ["</app>", "<links>"] + links + ["</links>", "</sedonaApp>", ""]
|
||||||
|
|
||||||
|
with open(OUT, "w", newline="\n") as fh:
|
||||||
|
fh.write("\n".join(xml))
|
||||||
|
print(f"{OUT}: {_id[0]} components, {len(links)} links")
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
"""Offline check of aspirin.sax logic: models the Sedona scan (children first, then
|
||||||
|
propagate links into a component, then execute) for the component types the app uses."""
|
||||||
|
import random
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
SCAN_MS = 100
|
||||||
|
|
||||||
|
|
||||||
|
class C:
|
||||||
|
def __init__(self, typ, props):
|
||||||
|
self.typ = typ.split("::")[1]
|
||||||
|
self.v = {}
|
||||||
|
self.kids = []
|
||||||
|
self.links_to = []
|
||||||
|
self.state = {}
|
||||||
|
for k, val in props.items():
|
||||||
|
if val in ("true", "false"):
|
||||||
|
self.v[k] = val == "true"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.v[k] = float(val)
|
||||||
|
except ValueError:
|
||||||
|
self.v[k] = val
|
||||||
|
|
||||||
|
def g(self, k, d=0.0):
|
||||||
|
return self.v.get(k, d)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.typ == "Count":
|
||||||
|
self.v["out"] = self.g("preset", 0)
|
||||||
|
self.state["last"] = False
|
||||||
|
if self.typ in ("OneShot", "DlyOn"):
|
||||||
|
self.state["last"] = False
|
||||||
|
self.state["end"] = 0
|
||||||
|
if self.typ == "SRLatch":
|
||||||
|
self.state["ls"] = self.state["lr"] = False
|
||||||
|
|
||||||
|
def execute(self, now):
|
||||||
|
g, v, t = self.g, self.v, self.typ
|
||||||
|
if t in ("Add2", "Add4"):
|
||||||
|
v["out"] = g("in1") + g("in2") + g("in3") + g("in4")
|
||||||
|
elif t == "Sub2":
|
||||||
|
v["out"] = g("in1") - g("in2")
|
||||||
|
elif t == "Mul2":
|
||||||
|
v["out"] = g("in1") * g("in2")
|
||||||
|
elif t == "Reset":
|
||||||
|
i, a, b, c, d = g("in"), g("inMin"), g("inMax"), g("outMin"), g("outMax")
|
||||||
|
v["out"] = c if i <= a else d if i >= b else (d - c) / (b - a) * (i - a) + c
|
||||||
|
elif t == "Linearize":
|
||||||
|
xs = [g(f"x{i}") for i in range(10)]
|
||||||
|
ys = [g(f"y{i}") for i in range(10)]
|
||||||
|
out = float("nan")
|
||||||
|
i = g("in")
|
||||||
|
for k in range(9):
|
||||||
|
lo_ok = i >= xs[0] if k == 0 else i > xs[k]
|
||||||
|
if lo_ok and i <= xs[k + 1]:
|
||||||
|
m = (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])
|
||||||
|
out = m * i + (ys[k] - m * xs[k])
|
||||||
|
v["out"] = out
|
||||||
|
elif t == "InRange":
|
||||||
|
v["out"] = g("lowLimit") <= g("in") <= g("highLimit")
|
||||||
|
elif t == "Cmpr":
|
||||||
|
v["xey"] = g("x") == g("y")
|
||||||
|
elif t == "And2":
|
||||||
|
v["out"] = bool(g("in1", False) and g("in2", False))
|
||||||
|
elif t == "And4":
|
||||||
|
v["out"] = all(g(f"in{k}", False) for k in range(1, 5))
|
||||||
|
elif t in ("Or2", "Or4"):
|
||||||
|
v["out"] = any(g(f"in{k}", False) for k in range(1, 5))
|
||||||
|
elif t == "Not":
|
||||||
|
v["out"] = not g("in", False)
|
||||||
|
elif t == "OneShot":
|
||||||
|
i = g("in", False)
|
||||||
|
if i and not self.state["last"]:
|
||||||
|
if not g("out", False):
|
||||||
|
self.state["end"] = now + g("pulseWidth") * 1000
|
||||||
|
v["out"] = self.state["end"] > now
|
||||||
|
self.state["last"] = i
|
||||||
|
elif t == "ADemux2":
|
||||||
|
if g("s1", False):
|
||||||
|
v["out2"] = g("in")
|
||||||
|
else:
|
||||||
|
v["out1"] = g("in")
|
||||||
|
elif t == "SRLatch":
|
||||||
|
if g("s", False) and not self.state["ls"]:
|
||||||
|
v["out"] = True
|
||||||
|
if g("r", False) and not self.state["lr"]:
|
||||||
|
v["out"] = False
|
||||||
|
self.state["ls"], self.state["lr"] = g("s", False), g("r", False)
|
||||||
|
elif t == "ASW":
|
||||||
|
v["out"] = g("in2") if g("s1", False) else g("in1")
|
||||||
|
elif t == "TickTock":
|
||||||
|
half = 500 / g("ticksPerSec", 1)
|
||||||
|
v["out"] = (now // half) % 2 == 0
|
||||||
|
elif t == "DlyOn":
|
||||||
|
i = g("in", False)
|
||||||
|
if i and not self.state["last"]:
|
||||||
|
self.state["end"] = now + g("delayTime") * 1000
|
||||||
|
if not i:
|
||||||
|
self.state["end"] = 0
|
||||||
|
v["out"] = False
|
||||||
|
else:
|
||||||
|
v["out"] = self.state["end"] < now
|
||||||
|
if v["out"]:
|
||||||
|
self.state["end"] = 0
|
||||||
|
self.state["last"] = i
|
||||||
|
elif t == "F2I":
|
||||||
|
v["out"] = float(int(g("in")))
|
||||||
|
elif t == "I2F":
|
||||||
|
v["out"] = g("in")
|
||||||
|
elif t == "Count":
|
||||||
|
if not g("enable", False):
|
||||||
|
return
|
||||||
|
c = g("out")
|
||||||
|
if g("in", False) and not self.state["last"]:
|
||||||
|
c += 1
|
||||||
|
if g("r", False):
|
||||||
|
c = g("preset", 0)
|
||||||
|
v["out"] = c
|
||||||
|
self.state["last"] = g("in", False)
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
paths = {}
|
||||||
|
order = []
|
||||||
|
|
||||||
|
def walk(el, prefix):
|
||||||
|
comps = []
|
||||||
|
for ce in el.findall("comp"):
|
||||||
|
props = {p.get("name"): p.get("val") for p in ce.findall("prop")}
|
||||||
|
c = C(ce.get("type"), props)
|
||||||
|
p = f"{prefix}/{ce.get('name')}"
|
||||||
|
paths[p] = c
|
||||||
|
c.kids = walk(ce, p)
|
||||||
|
comps.append(c)
|
||||||
|
return comps
|
||||||
|
|
||||||
|
tops = walk(root.find("app"), "")
|
||||||
|
for le in root.find("links").findall("link"):
|
||||||
|
fp, fs = le.get("from").rsplit(".", 1)
|
||||||
|
tp, ts = le.get("to").rsplit(".", 1)
|
||||||
|
paths[tp].links_to.append((paths[fp], fs, ts))
|
||||||
|
return tops, paths
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan(tops, now):
|
||||||
|
def ex(c):
|
||||||
|
for k in c.kids:
|
||||||
|
ex(k)
|
||||||
|
for src, fs, ts in c.links_to:
|
||||||
|
c.v[ts] = src.v.get(fs, 0.0)
|
||||||
|
c.execute(now)
|
||||||
|
for t in tops:
|
||||||
|
ex(t)
|
||||||
|
|
||||||
|
|
||||||
|
def board(paths):
|
||||||
|
grid = [[" ."] * 4 for _ in range(4)]
|
||||||
|
for t in range(1, 16):
|
||||||
|
w = paths[f"/ox/Game/Tile{t:02d}"]
|
||||||
|
col = int((w.g("x") - 40) // 105)
|
||||||
|
row = int((w.g("y") - 40) // 105)
|
||||||
|
grid[row][col] = f"{t:3d}"
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
def main_solver():
|
||||||
|
"""aspirin_solver.sax: exact-length shuffle, then one inverse move per second until solved."""
|
||||||
|
tops, paths = load("aspirin_solver.sax")
|
||||||
|
for c in paths.values():
|
||||||
|
c.start()
|
||||||
|
now = 0
|
||||||
|
goal = [[" 1", " 2", " 3", " 4"], [" 5", " 6", " 7", " 8"],
|
||||||
|
[" 9", " 10", " 11", " 12"], [" 13", " 14", " 15", " ."]]
|
||||||
|
snapshots, solve_times = [], []
|
||||||
|
last = board(paths)
|
||||||
|
shuffle_done_at = None
|
||||||
|
for _ in range(1200): # 120 s
|
||||||
|
now += SCAN_MS + random.randint(-3, 3)
|
||||||
|
run_scan(tops, now)
|
||||||
|
b = board(paths)
|
||||||
|
shuffling = paths["/logic/glob/Shuf"].g("out", False)
|
||||||
|
if b != last and not shuffling and shuffle_done_at is not None:
|
||||||
|
solve_times.append(now)
|
||||||
|
if not shuffling and shuffle_done_at is None and now > 1000:
|
||||||
|
shuffle_done_at = now
|
||||||
|
snapshots.append(b)
|
||||||
|
print("after shuffle:")
|
||||||
|
print("\n".join(" ".join(r) for r in b))
|
||||||
|
assert b != goal, "shuffle left board solved"
|
||||||
|
last = b
|
||||||
|
if shuffle_done_at and paths["/logic/post/Solved"].g("out", False):
|
||||||
|
break
|
||||||
|
assert paths["/logic/post/Solved"].g("out", False), "auto solver did not finish"
|
||||||
|
assert board(paths) == goal
|
||||||
|
gaps = [b - a for a, b in zip(solve_times, solve_times[1:])]
|
||||||
|
print(f"solved with {len(solve_times)} moves, first move {solve_times[0]-shuffle_done_at} ms "
|
||||||
|
f"after shuffle, gaps {min(gaps)}-{max(gaps)} ms, moves label "
|
||||||
|
f"{paths['/logic/post/CntF'].g('out')}")
|
||||||
|
assert len(solve_times) == 30 and min(gaps) >= 900
|
||||||
|
assert paths["/logic/stkReg/Empty"].g("out", False)
|
||||||
|
# SHUFFLE again
|
||||||
|
paths["/ox/Game/Shuffle"].v["touch"] = True
|
||||||
|
for _ in range(3):
|
||||||
|
now += SCAN_MS
|
||||||
|
run_scan(tops, now)
|
||||||
|
paths["/ox/Game/Shuffle"].v["touch"] = False
|
||||||
|
for _ in range(600):
|
||||||
|
now += SCAN_MS
|
||||||
|
run_scan(tops, now)
|
||||||
|
if paths["/logic/post/Solved"].g("out", False) and not paths["/logic/glob/Shuf"].g("out", False):
|
||||||
|
break
|
||||||
|
assert board(paths) == goal and paths["/logic/post/Solved"].g("out", False)
|
||||||
|
print("second shuffle + auto solve OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tops, paths = load("aspirin.sax")
|
||||||
|
for c in paths.values():
|
||||||
|
c.start()
|
||||||
|
now = 0
|
||||||
|
|
||||||
|
def step(n=1):
|
||||||
|
nonlocal now
|
||||||
|
for _ in range(n):
|
||||||
|
now += SCAN_MS + random.randint(-3, 3)
|
||||||
|
run_scan(tops, now)
|
||||||
|
|
||||||
|
def check_perm():
|
||||||
|
cells = [tuple(r) for r in board(paths)]
|
||||||
|
flat = [x for r in cells for x in r]
|
||||||
|
nums = sorted(int(x) for x in flat if x.strip() != ".")
|
||||||
|
assert nums == list(range(1, 16)), cells
|
||||||
|
|
||||||
|
step(120) # boot shuffle (10 s) + settle
|
||||||
|
check_perm()
|
||||||
|
g = board(paths)
|
||||||
|
print("after shuffle:")
|
||||||
|
print("\n".join(" ".join(r) for r in g))
|
||||||
|
solved = paths["/logic/post/Solved"].g("out", False)
|
||||||
|
print("solved flag:", solved, "moves:", paths["/logic/post/CntF"].g("out"))
|
||||||
|
assert not solved
|
||||||
|
|
||||||
|
def press(t):
|
||||||
|
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = True
|
||||||
|
step(2)
|
||||||
|
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = False
|
||||||
|
step(2)
|
||||||
|
|
||||||
|
# solve by replaying moves with a simple BFS-free strategy: random legal presses until solved is
|
||||||
|
# impractical, so instead verify mechanics: invalid press does nothing, valid press swaps.
|
||||||
|
def blank():
|
||||||
|
g = board(paths)
|
||||||
|
for r in range(4):
|
||||||
|
for c in range(4):
|
||||||
|
if g[r][c].strip() == ".":
|
||||||
|
return r, c
|
||||||
|
|
||||||
|
def tile_at(r, c):
|
||||||
|
return int(board(paths)[r][c])
|
||||||
|
|
||||||
|
br, bc = blank()
|
||||||
|
# pick a non-adjacent tile
|
||||||
|
far = next((r, c) for r in range(4) for c in range(4)
|
||||||
|
if abs(r - br) + abs(c - bc) >= 2)
|
||||||
|
before = board(paths)
|
||||||
|
press(tile_at(*far))
|
||||||
|
assert board(paths) == before, "non-adjacent tile moved"
|
||||||
|
moves0 = paths["/logic/post/CntF"].g("out")
|
||||||
|
# adjacent press
|
||||||
|
nr, nc = (br, bc - 1) if bc > 0 else (br, bc + 1)
|
||||||
|
t = tile_at(nr, nc)
|
||||||
|
press(t)
|
||||||
|
assert blank() == (nr, nc) and tile_at(br, bc) == t, "valid move failed"
|
||||||
|
assert paths["/logic/post/CntF"].g("out") == moves0 + 1
|
||||||
|
# holding the finger must not oscillate
|
||||||
|
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = True
|
||||||
|
step(10)
|
||||||
|
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = False
|
||||||
|
step(2)
|
||||||
|
assert blank() == (br, bc), "hold did not move exactly once"
|
||||||
|
check_perm()
|
||||||
|
|
||||||
|
# solve: undo using a solver on the current board (IDA* is overkill; use reverse of
|
||||||
|
# a fresh scramble instead): press SHUFFLE-free path -> brute force with BFS on small scramble
|
||||||
|
# Easier: drive tiles to solved using a known-solvable sequence from Python solver.
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def state():
|
||||||
|
g = board(paths)
|
||||||
|
return tuple(0 if x.strip() == "." else int(x) for r in g for x in r)
|
||||||
|
|
||||||
|
goal = tuple(list(range(1, 16)) + [0])
|
||||||
|
# A* with manhattan heuristic
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
def h(s):
|
||||||
|
d = 0
|
||||||
|
for i, v in enumerate(s):
|
||||||
|
if v:
|
||||||
|
d += abs(i // 4 - (v - 1) // 4) + abs(i % 4 - (v - 1) % 4)
|
||||||
|
return d
|
||||||
|
|
||||||
|
s0 = state()
|
||||||
|
pq = [(h(s0), 0, s0, [])]
|
||||||
|
seen = {s0: 0}
|
||||||
|
path = None
|
||||||
|
while pq:
|
||||||
|
f, gc, s, p = heapq.heappop(pq)
|
||||||
|
if s == goal:
|
||||||
|
path = p
|
||||||
|
break
|
||||||
|
z = s.index(0)
|
||||||
|
for dz in (-1, 1, -4, 4):
|
||||||
|
n = z + dz
|
||||||
|
if n < 0 or n > 15 or (dz in (-1, 1) and n // 4 != z // 4):
|
||||||
|
continue
|
||||||
|
l = list(s)
|
||||||
|
l[z], l[n] = l[n], l[z]
|
||||||
|
ns = tuple(l)
|
||||||
|
if seen.get(ns, 1e9) > gc + 1:
|
||||||
|
seen[ns] = gc + 1
|
||||||
|
heapq.heappush(pq, (gc + 1 + 1.5 * h(ns), gc + 1, ns, p + [s[n]]))
|
||||||
|
if len(seen) > 2_000_000:
|
||||||
|
break
|
||||||
|
assert path is not None, "solver gave up"
|
||||||
|
print("solving in", len(path), "presses")
|
||||||
|
for t in path:
|
||||||
|
press(t)
|
||||||
|
step(25)
|
||||||
|
print("\n".join(" ".join(r) for r in board(paths)))
|
||||||
|
assert state() == goal
|
||||||
|
assert paths["/logic/post/Solved"].g("out", False), "solved flag not set"
|
||||||
|
print("SOLVED flag OK, moves:", paths["/logic/post/CntF"].g("out"))
|
||||||
|
|
||||||
|
# SHUFFLE button re-scrambles and clears the flag/counter
|
||||||
|
paths["/ox/Game/Shuffle"].v["touch"] = True
|
||||||
|
step(2)
|
||||||
|
paths["/ox/Game/Shuffle"].v["touch"] = False
|
||||||
|
step(120)
|
||||||
|
check_perm()
|
||||||
|
assert state() != goal and not paths["/logic/post/Solved"].g("out", False)
|
||||||
|
assert paths["/logic/post/CntF"].g("out") == 0
|
||||||
|
print("re-shuffle OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
main_solver() if "--solver" in sys.argv else main()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import java.awt.*;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import javax.swing.*;
|
||||||
|
import sedona.*;
|
||||||
|
import sedona.dasp.DaspSocket;
|
||||||
|
import sedona.sox.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live viewer for the aspirin apps running in the Ontrol simulator (headless SVM).
|
||||||
|
* Connects over SOX, polls /ox/Game widgets 5x per second and draws the 800x480 Orion page.
|
||||||
|
* Usage: java -cp ".;sedona.jar" OxViewer [host] [port] [user] [password]
|
||||||
|
*/
|
||||||
|
public class OxViewer extends JPanel {
|
||||||
|
static final String[] LABELS = {"Title", "Sub", "Moves", "Shuffle", "Busy", "Solving", "Solved"};
|
||||||
|
SoxClient client;
|
||||||
|
SoxComponent page;
|
||||||
|
SoxComponent[] tiles = new SoxComponent[16];
|
||||||
|
SoxComponent[] labels = new SoxComponent[LABELS.length];
|
||||||
|
String lastBoard = "";
|
||||||
|
|
||||||
|
OxViewer(SoxClient client) throws Exception {
|
||||||
|
this.client = client;
|
||||||
|
SoxComponent app = client.loadApp();
|
||||||
|
page = load(app, "ox", "Game");
|
||||||
|
for (int t = 1; t <= 15; t++) tiles[t] = load(page, String.format("Tile%02d", t));
|
||||||
|
for (int i = 0; i < LABELS.length; i++) labels[i] = load(page, LABELS[i]);
|
||||||
|
setPreferredSize(new Dimension(800, 480));
|
||||||
|
}
|
||||||
|
|
||||||
|
SoxComponent load(SoxComponent from, String... path) throws Exception {
|
||||||
|
SoxComponent c = from;
|
||||||
|
for (String p : path) {
|
||||||
|
if (c == null) return null;
|
||||||
|
client.update(c, 0x01); // tree
|
||||||
|
c = c.child(p);
|
||||||
|
}
|
||||||
|
if (c != null) client.update(c, 0x06); // config + runtime
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll() throws Exception {
|
||||||
|
for (SoxComponent c : tiles) if (c != null) client.update(c, 0x06);
|
||||||
|
for (SoxComponent c : labels) if (c != null) client.update(c, 0x06);
|
||||||
|
String b = boardText();
|
||||||
|
if (!b.equals(lastBoard)) {
|
||||||
|
System.out.println(b + " moves=" + str(labels[2], "in") + " " + status());
|
||||||
|
System.out.flush();
|
||||||
|
lastBoard = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value val(SoxComponent c, String slot) {
|
||||||
|
Slot s = c.type.slot(slot);
|
||||||
|
return s == null ? null : c.get(s);
|
||||||
|
}
|
||||||
|
static int i(SoxComponent c, String slot) { return Integer.parseInt(val(c, slot).toString()); }
|
||||||
|
static String str(SoxComponent c, String slot) {
|
||||||
|
if (c == null) return "";
|
||||||
|
Value v = val(c, slot);
|
||||||
|
if (v == null) return "";
|
||||||
|
if (v instanceof Buf) { Buf b = (Buf) v; return new String(b.bytes, 0, b.size).replace("\0", ""); }
|
||||||
|
String s = v.toString();
|
||||||
|
return s.endsWith(".0") ? s.substring(0, s.length() - 2) : s;
|
||||||
|
}
|
||||||
|
static Color c565(int v) {
|
||||||
|
v &= 0xFFFF;
|
||||||
|
return new Color(((v >> 11) & 31) * 255 / 31, ((v >> 5) & 63) * 255 / 63, (v & 31) * 255 / 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
String status() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int k = 4; k < LABELS.length; k++)
|
||||||
|
if (labels[k] != null && "true".equals(str(labels[k], "in"))) sb.append(str(labels[k], "trueText")).append(' ');
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String boardText() {
|
||||||
|
String[] cells = new String[16];
|
||||||
|
java.util.Arrays.fill(cells, " .");
|
||||||
|
for (int t = 1; t <= 15; t++) {
|
||||||
|
int col = (i(tiles[t], "x") - 40) / 105, row = (i(tiles[t], "y") - 40) / 105;
|
||||||
|
if (col >= 0 && col < 4 && row >= 0 && row < 4) cells[row * 4 + col] = String.format("%2d", t);
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int r = 0; r < 4; r++) sb.append(r == 0 ? "" : " | ").append(String.join(" ", java.util.Arrays.copyOfRange(cells, r * 4, r * 4 + 4)));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void box(Graphics2D g, SoxComponent w, String text, int fg, int bg, boolean bold, boolean fill) {
|
||||||
|
int x = i(w, "x"), y = i(w, "y"), wd = i(w, "width"), ht = i(w, "height");
|
||||||
|
if (fill) { g.setColor(c565(bg)); g.fillRect(x, y, wd, ht); }
|
||||||
|
g.setColor(c565(fg));
|
||||||
|
g.setFont(new Font("Tahoma", bold ? Font.BOLD : Font.PLAIN, Math.max(9, i(w, "fontSize") * 3 / 4)));
|
||||||
|
FontMetrics fm = g.getFontMetrics();
|
||||||
|
g.drawString(text, x + (wd - fm.stringWidth(text)) / 2, y + (ht + fm.getAscent() - fm.getDescent()) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void paintComponent(Graphics g0) {
|
||||||
|
Graphics2D g = (Graphics2D) g0;
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||||
|
g.setColor(c565(i(page, "backgroundColor")));
|
||||||
|
g.fillRect(0, 0, 800, 480);
|
||||||
|
g.setColor(c565(0x31A6));
|
||||||
|
g.fillRect(35, 35, 425, 425);
|
||||||
|
for (int t = 1; t <= 15; t++) {
|
||||||
|
SoxComponent w = tiles[t];
|
||||||
|
box(g, w, str(w, "text"), i(w, "textColor"), i(w, "backgroundColor"), true, true);
|
||||||
|
}
|
||||||
|
for (int k = 0; k < LABELS.length; k++) {
|
||||||
|
SoxComponent w = labels[k];
|
||||||
|
if (w == null) continue;
|
||||||
|
String type = w.type.name;
|
||||||
|
if (type.equals("BooleanLabel")) {
|
||||||
|
boolean on = "true".equals(str(w, "in"));
|
||||||
|
box(g, w, str(w, on ? "trueText" : "falseText"), i(w, on ? "trueTextColor" : "falseTextColor"),
|
||||||
|
i(w, on ? "trueBackgroundColor" : "falseBackgroundColor"), true, true);
|
||||||
|
} else if (type.equals("NumericLabel")) {
|
||||||
|
String fmt = str(w, "text").replace("%.0f", str(w, "in"));
|
||||||
|
box(g, w, fmt, i(w, "textColor"), i(w, "backgroundColor"), false, true);
|
||||||
|
} else {
|
||||||
|
box(g, w, str(w, "text"), i(w, "textColor"), i(w, "backgroundColor"), i(w, "fontAttributes") == 1, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] a) throws Exception {
|
||||||
|
String host = a.length > 0 ? a[0] : "127.0.0.1";
|
||||||
|
int port = a.length > 1 ? Integer.parseInt(a[1]) : 1876;
|
||||||
|
String user = a.length > 2 ? a[2] : "guest";
|
||||||
|
String pass = a.length > 3 ? a[3] : "";
|
||||||
|
DaspSocket sock = DaspSocket.open(-1, null, DaspSocket.SESSION_QUEUING);
|
||||||
|
OxViewer v = null;
|
||||||
|
while (v == null) { // wait for the simulator to come up
|
||||||
|
try {
|
||||||
|
SoxClient client = new SoxClient(sock, InetAddress.getByName(host), port, user, pass);
|
||||||
|
client.connect();
|
||||||
|
v = new OxViewer(client);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("waiting for SOX " + host + ":" + port + " (" + e + ")");
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JFrame f = new JFrame("Orion Screen - aspirin (live via SOX " + host + ":" + port + ")");
|
||||||
|
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||||
|
f.add(v);
|
||||||
|
f.pack();
|
||||||
|
f.setLocation(60, 60);
|
||||||
|
f.setVisible(true);
|
||||||
|
while (true) {
|
||||||
|
v.poll();
|
||||||
|
v.repaint();
|
||||||
|
Thread.sleep(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
rem Live Orion screen for the aspirin apps running in simulatorLauncher (SOX 127.0.0.1:1876, guest).
|
||||||
|
rem Start it before or after "Start Simulator"; it waits for the simulator to come up.
|
||||||
|
setlocal
|
||||||
|
set "J=C:\Niagara\Niagara-4.15.1.16\jre\bin"
|
||||||
|
set "S=C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona"
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if not exist OxViewer.class "%J%\javac.exe" -cp "%S%\lib\sedona.jar" OxViewer.java
|
||||||
|
"%J%\java.exe" "-Dsedona.home=%S%" -cp ".;%S%\lib\sedona.jar" OxViewer %*
|
||||||
Reference in New Issue
Block a user