tools: group scripts by what runs them
tools/windows/ .ps1 (pwsh, so usable from Linux and macOS too)
tools/posix/ .sh
tools/python/ .py
tools/ .c and .pl
The .c and .pl files stay at the top level because they are not
host-specific: the build compiles or invokes them on every platform.
Call sites updated in the Makefile, ports/mk/port.mk, docs and the
project memory. The one thing that does not survive a move on its own is
a script deriving the repo root from its own location -- all nine
PowerShell tools used Split-Path $PSScriptRoot -Parent, and memory-sync.sh
used dirname/.., both of which quietly become tools/ one level deeper.
Two levels now.
Two pre-existing Windows failures surfaced while verifying, both real and
neither caused by the move:
- schfs_fsck.c did not compile under mingw at all: --extract uses
POSIX mkdir(path,mode), symlink() and link(), none of which mingw
has. That took the whole tool with it, and `make schfs-check` with
it. mkdir maps to _mkdir; symlink and link fail loudly rather than
writing a regular file in their place, because an extracted tree
that differs from the image while reporting success defeats the
point of --extract.
- the round-trip test then failed at `ln -s`, since Git Bash cannot
create symlinks without developer mode. It skips with a message now.
Not a fudge: mkschfs's mingw build cannot see a symlink either, so
the test would have compared a tree with none against an image with
none and called it a pass -- coverage reporting itself as present
while being absent.
Verified on Windows: build clean, all seven bootcheck suites green,
make schfs-check passes.
This commit is contained in:
parent
531ef120ef
commit
754d96ebc5
26 changed files with 127 additions and 39 deletions
85
tools/python/crashqr.py
Normal file
85
tools/python/crashqr.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read the QR code back off a captured crash screen.
|
||||
|
||||
This is the acceptance test for the crash channel, and it is deliberately
|
||||
end-to-end: it does not ask the encoder what it produced, it reads the pixels
|
||||
that were actually painted on the framebuffer. Everything in between -- payload
|
||||
construction in panic context, the encoder, the scaling into modules, the card
|
||||
and quiet zone -- is covered by whether this comes back as text.
|
||||
|
||||
pwsh -File tools/crashshot.ps1 # crash on purpose, screendump it
|
||||
PYTHONPATH=<dir> python tools/crashqr.py
|
||||
|
||||
Needs opencv-python-headless and numpy; see tools/qrcheck.py for the install.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
sys.exit("crashqr: needs opencv-python-headless and numpy")
|
||||
|
||||
PPM = sys.argv[1] if len(sys.argv) > 1 else "build/crashscreen.ppm"
|
||||
|
||||
try:
|
||||
data = open(PPM, "rb").read()
|
||||
except OSError as e:
|
||||
sys.exit(f"crashqr: {e} -- run tools/crashshot.ps1 first")
|
||||
|
||||
# PPM P6 header: three whitespace-separated fields after the magic. Parsed by
|
||||
# scanning rather than assuming a fixed length, because QEMU's is compact but
|
||||
# nothing guarantees that.
|
||||
pos, fields = 2, []
|
||||
while len(fields) < 3:
|
||||
while data[pos:pos + 1].isspace():
|
||||
pos += 1
|
||||
start = pos
|
||||
while not data[pos:pos + 1].isspace():
|
||||
pos += 1
|
||||
fields.append(int(data[start:pos]))
|
||||
pos += 1
|
||||
|
||||
w, h = fields[0], fields[1]
|
||||
img = np.frombuffer(data[pos:pos + w * h * 3], np.uint8).reshape(h, w, 3)
|
||||
grey = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
# The symbol sits on the only light panel on an otherwise purple screen, so a
|
||||
# brightness threshold finds it without needing to know the layout. The white
|
||||
# report text passes the same threshold, which does not matter -- the detector
|
||||
# locates the symbol by its finder patterns, and handing it extra screen
|
||||
# costs nothing.
|
||||
mask = (grey > 240).astype(np.uint8)
|
||||
ys, xs = np.nonzero(mask)
|
||||
if len(xs) == 0:
|
||||
sys.exit("crashqr: no light region found -- was a crash screen captured?")
|
||||
crop = grey[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
||||
|
||||
# OpenCV's detector is sensitive to scale in ways unrelated to the encoding,
|
||||
# so try a few before concluding anything. See the note in tools/qrcheck.py.
|
||||
text = ""
|
||||
for scale in (1, 2, 3, 4):
|
||||
up = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST)
|
||||
up = cv2.copyMakeBorder(up, 40, 40, 40, 40, cv2.BORDER_CONSTANT, value=255)
|
||||
text, _, _ = cv2.QRCodeDetector().detectAndDecode(up)
|
||||
if text:
|
||||
break
|
||||
|
||||
if not text:
|
||||
print("crashqr: FAILED -- the symbol on the crash screen did not decode")
|
||||
sys.exit(1)
|
||||
|
||||
print("crashqr: decoded the crash screen's QR\n")
|
||||
for line in text.split("\n"):
|
||||
print(" ", line)
|
||||
|
||||
# The payload is a contract with whatever reads these later, so check its shape
|
||||
# rather than only that something came back.
|
||||
lines = text.split("\n")
|
||||
ok = lines[0] == "SXPSOGM1"
|
||||
print()
|
||||
if not ok:
|
||||
print(f"crashqr: FAILED -- expected magic 'SXPSOGM1', got {lines[0]!r}")
|
||||
sys.exit(1)
|
||||
print(f"crashqr: passed ({len(text)} bytes, {len(lines)} fields)")
|
||||
184
tools/python/mklogo.py
Normal file
184
tools/python/mklogo.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Turn Assets/*.png into a C header the kernel can draw.
|
||||
|
||||
Why this exists rather than a PNG decoder in the kernel: the boot logo is one
|
||||
fixed image that changes about never, so decoding it at runtime would mean
|
||||
carrying an inflate implementation in the kernel forever to answer a question
|
||||
that is already known at build time. Precedent is include/font.h, which is the
|
||||
same trade for the same reason.
|
||||
|
||||
Output is an 8-bit coverage mask, not colour. The logo is a single-colour
|
||||
silhouette (a monogram in a ring), so what matters is *how much* of each pixel
|
||||
is ink -- which lets the kernel composite it in whatever the theme's accent
|
||||
happens to be, anti-aliased, at one byte per pixel. Storing RGB would be four
|
||||
times the size and would hardcode a colour into the kernel image.
|
||||
|
||||
python3 tools/mklogo.py "Assets/SchOS Small Logo.png" include/logo.h 128
|
||||
"""
|
||||
|
||||
import sys, zlib, struct
|
||||
|
||||
|
||||
def read_png(path):
|
||||
d = open(path, 'rb').read()
|
||||
if d[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
raise SystemExit(f'{path}: not a PNG')
|
||||
pos, idat, w, h, bd, ct = 8, b'', 0, 0, 0, 0
|
||||
while pos < len(d):
|
||||
ln, typ = struct.unpack('>I4s', d[pos:pos + 8])
|
||||
body = d[pos + 8:pos + 8 + ln]
|
||||
if typ == b'IHDR':
|
||||
w, h, bd, ct = struct.unpack('>IIBB', body[:10])
|
||||
elif typ == b'IDAT':
|
||||
idat += body
|
||||
elif typ == b'IEND':
|
||||
break
|
||||
pos += 12 + ln
|
||||
if bd != 8 or ct not in (2, 6):
|
||||
raise SystemExit(f'{path}: need 8-bit RGB or RGBA, got depth={bd} type={ct}')
|
||||
nch = 4 if ct == 6 else 3
|
||||
raw = zlib.decompress(idat)
|
||||
|
||||
# Undo PNG's per-scanline filters. Each row is prefixed with a filter byte
|
||||
# and predicted from the row above and the pixel to the left; without this
|
||||
# step the data is not an image at all.
|
||||
stride = w * nch
|
||||
out = bytearray(h * stride)
|
||||
prev = bytearray(stride)
|
||||
p = 0
|
||||
for y in range(h):
|
||||
f = raw[p]; p += 1
|
||||
cur = bytearray(raw[p:p + stride]); p += stride
|
||||
if f == 1:
|
||||
for i in range(nch, stride):
|
||||
cur[i] = (cur[i] + cur[i - nch]) & 0xFF
|
||||
elif f == 2:
|
||||
for i in range(stride):
|
||||
cur[i] = (cur[i] + prev[i]) & 0xFF
|
||||
elif f == 3:
|
||||
for i in range(stride):
|
||||
a = cur[i - nch] if i >= nch else 0
|
||||
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xFF
|
||||
elif f == 4:
|
||||
for i in range(stride):
|
||||
a = cur[i - nch] if i >= nch else 0
|
||||
b = prev[i]
|
||||
c = prev[i - nch] if i >= nch else 0
|
||||
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
|
||||
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
|
||||
cur[i] = (cur[i] + pr) & 0xFF
|
||||
elif f != 0:
|
||||
raise SystemExit(f'unknown PNG filter {f}')
|
||||
out[y * stride:(y + 1) * stride] = cur
|
||||
prev = cur
|
||||
return w, h, nch, out
|
||||
|
||||
|
||||
def ink(px, nch, x, y, w):
|
||||
"""How much ink is at (x, y), 0..255.
|
||||
|
||||
The source is a dark mark on white with an alpha channel. Ink is therefore
|
||||
'opaque and not white': darkness weighted by alpha. Using luminance alone
|
||||
would make the transparent margin count as black.
|
||||
"""
|
||||
i = (y * w + x) * nch
|
||||
r, g, b = px[i], px[i + 1], px[i + 2]
|
||||
a = px[i + 3] if nch == 4 else 255
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
return ((255 - lum) * a) // 255
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit(__doc__)
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
size = int(sys.argv[3]) if len(sys.argv) > 3 else 128
|
||||
|
||||
w, h, nch, px = read_png(src)
|
||||
|
||||
# Crop to the mark. These files are 1920x1080 with the logo floating in a
|
||||
# large empty field; scaling that whole canvas down would waste most of the
|
||||
# output on margin and leave the mark tiny.
|
||||
x0, y0, x1, y1 = w, h, -1, -1
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if ink(px, nch, x, y, w) > 24:
|
||||
if x < x0: x0 = x
|
||||
if y < y0: y0 = y
|
||||
if x > x1: x1 = x
|
||||
if y > y1: y1 = y
|
||||
if x1 < 0:
|
||||
raise SystemExit(f'{src}: no non-white pixels found')
|
||||
|
||||
# Square the crop so the aspect ratio survives the scale.
|
||||
cw, ch = x1 - x0 + 1, y1 - y0 + 1
|
||||
side = max(cw, ch)
|
||||
x0 -= (side - cw) // 2
|
||||
y0 -= (side - ch) // 2
|
||||
|
||||
# Box filter. Averaging every source pixel that lands in an output cell is
|
||||
# what keeps the ring's edge smooth; nearest-neighbour at this reduction
|
||||
# ratio (roughly 8:1) would break it into dashes.
|
||||
mask = bytearray(size * size)
|
||||
for oy in range(size):
|
||||
sy0 = y0 + oy * side // size
|
||||
sy1 = max(sy0 + 1, y0 + (oy + 1) * side // size)
|
||||
for ox in range(size):
|
||||
sx0 = x0 + ox * side // size
|
||||
sx1 = max(sx0 + 1, x0 + (ox + 1) * side // size)
|
||||
tot = n = 0
|
||||
for sy in range(sy0, sy1):
|
||||
if sy < 0 or sy >= h: continue
|
||||
for sx in range(sx0, sx1):
|
||||
if sx < 0 or sx >= w: continue
|
||||
tot += ink(px, nch, sx, sy, w); n += 1
|
||||
mask[oy * size + ox] = tot // n if n else 0
|
||||
|
||||
# Normalise to full strength. The source art is a mid-grey gradient on
|
||||
# white, so its darkest pixel is only about 60% "ink" -- composited as-is
|
||||
# against a near-black background the mark comes out muddy. Scaling by the
|
||||
# peak keeps the gradient's internal shape (the relative values are
|
||||
# unchanged) while letting the brightest part of the logo actually reach the
|
||||
# accent colour.
|
||||
peak = max(mask)
|
||||
if peak:
|
||||
for i, v in enumerate(mask):
|
||||
mask[i] = min(255, v * 255 // peak)
|
||||
|
||||
# Pack two pixels per byte. This header is included by the *bootloader* as
|
||||
# well as the kernel, and stage2 has to fit between 0x7E00 and the 0x10000
|
||||
# kernel-search buffer -- so the mask's size is a boot-time constraint, not
|
||||
# just a matter of taste. 16 grey levels is visually indistinguishable from
|
||||
# 256 for a single-colour mark, and halves the cost.
|
||||
packed = bytearray(len(mask) // 2)
|
||||
for i in range(0, len(mask), 2):
|
||||
packed[i // 2] = ((mask[i] // 17) << 4) | (mask[i + 1] // 17)
|
||||
|
||||
with open(dst, 'w', newline='\n') as f:
|
||||
f.write('#pragma once\n\n')
|
||||
f.write('// GENERATED by tools/mklogo.py -- do not edit. Run `make logo`.\n')
|
||||
f.write(f'// source: {src}\n')
|
||||
f.write('//\n')
|
||||
f.write('// An ink-coverage mask, not colour: 0 is background and full scale is\n')
|
||||
f.write('// solid logo. Whoever draws it picks the colour, so the palette stays in\n')
|
||||
f.write('// include/theme.h and can change without regenerating anything.\n')
|
||||
f.write('//\n')
|
||||
f.write('// Two pixels per byte, high nibble first. Included by boot/stage2main.c\n')
|
||||
f.write('// as well as kernel/dev/vga.c, and stage2 must fit below the 0x10000\n')
|
||||
f.write('// kernel-search buffer -- hence 4 bits rather than 8.\n\n')
|
||||
f.write(f'#define LOGO_W {size}\n#define LOGO_H {size}\n\n')
|
||||
f.write('// Ink at linear pixel index i, 0..255.\n')
|
||||
f.write('#define LOGO_INK(i) ((unsigned char)((((i) & 1) '
|
||||
'? (logo_mask[(i) >> 1] & 0xF) \\\n')
|
||||
f.write(' : (logo_mask[(i) >> 1] >> 4)) * 17))\n\n')
|
||||
f.write('static const unsigned char logo_mask[LOGO_W * LOGO_H / 2] = {\n')
|
||||
for i in range(0, len(packed), 16):
|
||||
f.write(' ' + ','.join('0x%02x' % b for b in packed[i:i + 16]) + ',\n')
|
||||
f.write('};\n')
|
||||
|
||||
nz = sum(1 for b in mask if b)
|
||||
print(f'{dst}: {size}x{size}, cropped from {cw}x{ch} at ({x0},{y0}), '
|
||||
f'{nz} inked pixels, {len(packed)} bytes packed (4bpp)')
|
||||
|
||||
|
||||
main()
|
||||
185
tools/python/qrcheck.py
Normal file
185
tools/python/qrcheck.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Cross-check kernel/core/qr.c against independent implementations.
|
||||
|
||||
The self-checks inside tools/qrtest.c verify what has a local notion of
|
||||
correctness -- table arithmetic, the Reed-Solomon generator polynomials, whether
|
||||
a finder pattern is where it should be. They cannot verify the parts that only
|
||||
mean something to a decoder: zigzag placement order, mask selection, and block
|
||||
interleaving. All three have the same failure mode, which is a symbol that looks
|
||||
exactly like a QR code and is noise.
|
||||
|
||||
Two oracles, in order of authority:
|
||||
|
||||
1. **A real decoder** (OpenCV). Does the symbol read back as the exact bytes
|
||||
that went in? This is the only test that matches what the feature is for --
|
||||
somebody pointing a phone at a crashed machine -- and it is the one that
|
||||
decides pass or fail.
|
||||
|
||||
2. **A second encoder** (segno), matrix compared module for module. Stricter
|
||||
than decoding when it agrees, because it pins down the exact mask and
|
||||
placement rather than merely something readable. Applied only to payloads
|
||||
that exactly fill their version, for the reason below.
|
||||
|
||||
segno adds a spurious zero pad codeword to every byte-mode symbol. Its
|
||||
write_padding_bits() does `buff.extend([0] * (8 - (length % 8)))`, which
|
||||
emits a whole byte when the stream is already on a codeword boundary -- and
|
||||
in byte mode the stream is always 8L+16 bits after the terminator, so it
|
||||
always is. ISO/IEC 18004 makes that conditional ("if the bit stream length is
|
||||
such that it does not end at a codeword boundary"), and segno quotes exactly
|
||||
that sentence above the line. It is harmless -- decoders read `count` bytes
|
||||
and ignore padding -- but it means the two encoders cannot agree bit-for-bit
|
||||
on any payload that needs padding, so those are compared by decoding only.
|
||||
|
||||
pip install --target <dir> opencv-python-headless numpy segno
|
||||
PYTHONPATH=<dir> python tools/qrcheck.py
|
||||
|
||||
Requires build/tools/qrtest (make qrtest). Both libraries are optional; whatever
|
||||
is installed gets used.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Normalised and absolute: Windows CreateProcess rejects a relative path written
|
||||
# with forward slashes, even when the file is plainly there.
|
||||
QRTEST = os.path.abspath(os.path.join("build", "tools",
|
||||
"qrtest.exe" if os.name == "nt" else "qrtest"))
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
cv2 = None
|
||||
try:
|
||||
import segno
|
||||
except ImportError:
|
||||
segno = None
|
||||
|
||||
if cv2 is None and segno is None:
|
||||
sys.exit("qrcheck: needs opencv-python-headless and/or segno")
|
||||
if not os.path.exists(QRTEST):
|
||||
sys.exit(f"qrcheck: {QRTEST} not found -- run `make qrtest`")
|
||||
|
||||
|
||||
def ours(text):
|
||||
"""Our encoder's matrix, as a list of strings of '0'/'1'."""
|
||||
out = subprocess.run([QRTEST, "-m", text], capture_output=True, text=True)
|
||||
if out.returncode != 0:
|
||||
raise RuntimeError(f"qrtest failed on {text!r}")
|
||||
return out.stdout.split()
|
||||
|
||||
|
||||
def our_mask(m):
|
||||
"""The mask our encoder chose, read back out of the format bits.
|
||||
|
||||
Read from the symbol rather than reported by it, because the point is to
|
||||
check what was actually written.
|
||||
"""
|
||||
bits = 0
|
||||
for i in range(15):
|
||||
if i <= 5: b = int(m[i][8])
|
||||
elif i == 6: b = int(m[7][8])
|
||||
elif i == 7: b = int(m[8][8])
|
||||
elif i == 8: b = int(m[8][7])
|
||||
else: b = int(m[8][14 - i])
|
||||
bits |= b << i
|
||||
return ((bits ^ 0x5412) >> 10) & 7
|
||||
|
||||
|
||||
def render(m, scale, border):
|
||||
n = len(m)
|
||||
side = (n + 2 * border) * scale
|
||||
img = np.full((side, side), 255, np.uint8)
|
||||
for y in range(n):
|
||||
for x in range(n):
|
||||
if m[y][x] == "1":
|
||||
img[(y + border) * scale:(y + border + 1) * scale,
|
||||
(x + border) * scale:(x + border + 1) * scale] = 0
|
||||
return img
|
||||
|
||||
|
||||
# OpenCV's detector is not a reference implementation and does fail on symbols
|
||||
# that are perfectly valid -- sensitive to scale and quiet-zone width in ways
|
||||
# that have nothing to do with the encoding. Several renderings are tried before
|
||||
# concluding anything, and a payload that still will not decode is re-tested
|
||||
# against segno's symbol for the same input: if that fails too, the detector is
|
||||
# what is at fault, not the encoder. Without that second step this check reports
|
||||
# false failures, which is worse than not running it.
|
||||
RENDERINGS = [(8, 4), (6, 6), (8, 6), (4, 4), (12, 6), (16, 4)]
|
||||
|
||||
|
||||
def decode(m):
|
||||
for scale, border in RENDERINGS:
|
||||
text, _, _ = cv2.QRCodeDetector().detectAndDecode(render(m, scale, border))
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
# Byte capacity per version: total - ecc*blocks - 2. Mirrors the table in qr.c;
|
||||
# the exact-fill cases below are built from it, and a disagreement would show up
|
||||
# as a version mismatch rather than passing quietly.
|
||||
CAPACITY = {1: 17, 2: 32, 3: 53, 4: 78, 5: 106, 6: 134}
|
||||
|
||||
alpha = lambda n: "".join(chr(65 + i % 26) for i in range(n))
|
||||
|
||||
CASES = [
|
||||
"HELLO",
|
||||
"SCHOS",
|
||||
"a",
|
||||
"0123456789",
|
||||
"SCHOS PANIC\ntrap 14 err 4 cpu 0\nrip ffffffff80104e2a",
|
||||
bytes(range(1, 40)).decode("latin-1"), # non-printable payload
|
||||
]
|
||||
# One payload that exactly fills each version, and one just under, so both the
|
||||
# padded and unpadded paths are covered at every version -- including 6, which
|
||||
# is the only one with more than one Reed-Solomon block.
|
||||
for v, cap in CAPACITY.items():
|
||||
CASES.append(alpha(cap))
|
||||
CASES.append(alpha(cap - 1))
|
||||
|
||||
fails = 0
|
||||
print("decode (OpenCV)" if cv2 else "decode -- skipped, no opencv")
|
||||
for text in CASES:
|
||||
if not cv2:
|
||||
break
|
||||
m = ours(text)
|
||||
v = (len(m) - 17) // 4
|
||||
got = decode(m)
|
||||
if got == text:
|
||||
print(f" [ok] v{v} {len(text):3d}B")
|
||||
continue
|
||||
|
||||
# Before blaming the encoder, check whether an independently generated
|
||||
# symbol for the same payload decodes either.
|
||||
if segno and not decode([
|
||||
"".join(str(x) for x in row)
|
||||
for row in segno.make(text.encode("latin-1"), error="l", version=v,
|
||||
boost_error=False, mode="byte").matrix]):
|
||||
print(f" [detector] v{v} {len(text):3d}B -- segno's symbol does not "
|
||||
f"decode either; OpenCV's limitation, not ours")
|
||||
continue
|
||||
|
||||
fails += 1
|
||||
print(f" [FAIL] v{v} {len(text):3d}B decoded {got[:40]!r}")
|
||||
|
||||
print("\nmatrix vs segno (exact-fill payloads only)" if segno else "\nsegno -- skipped")
|
||||
for v, cap in CAPACITY.items():
|
||||
if not segno:
|
||||
break
|
||||
text = alpha(cap)
|
||||
m = ours(text)
|
||||
mask = our_mask(m)
|
||||
q = segno.make(text.encode("latin-1"), error="l", version=v,
|
||||
mask=mask, boost_error=False, mode="byte")
|
||||
ref = ["".join(str(x) for x in row) for row in q.matrix]
|
||||
ok = m == ref
|
||||
fails += not ok
|
||||
n = len(m)
|
||||
diff = "" if ok else f" {sum(1 for a, b in zip(''.join(m), ''.join(ref)) if a != b)}/{n*n} differ"
|
||||
print(f" [{'ok' if ok else 'FAIL'}] v{v} mask {mask} {cap:3d}B{diff}")
|
||||
|
||||
print()
|
||||
print("qrcheck: FAILED" if fails else "qrcheck: passed")
|
||||
sys.exit(1 if fails else 0)
|
||||
Loading…
Reference in a new issue