mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-28 21:46:21 -05:00
Fix scanner reading battle log AM times as PM and X Power caught mid-count
This commit is contained in:
@@ -302,7 +302,10 @@ sequenceDiagram
|
||||
order-free (a misread or unread row still joins), stage not contradicting
|
||||
it, recording time within 20 min of its play time — joins that match's sources instead of forming a new
|
||||
one, so browsing the log after playing neither adds a card nor re-uploads
|
||||
(the match was already sent). Any other history screen closes the game
|
||||
(the match was already sent). The recording time's AM/PM letter reads as
|
||||
"o" either way, so the header tells them apart by the letter's bottom rows
|
||||
(A stands on two legs, P on its stem): a flipped one puts the time 12h off
|
||||
and the screen turns into a duplicate card. Any other history screen closes the game
|
||||
being gathered — a missed results screen amended from the log — unless
|
||||
the intro/minimap stage, intro mode or recording time (vs. the game's
|
||||
first read) contradicts it. Then, or with no game being gathered, it
|
||||
@@ -810,7 +813,10 @@ direction. So no detector sets `sufficientConfidence` (the first clean read
|
||||
is the stale one), each gate returns a `signature` over its numbers, and the
|
||||
timeline orders same-type reads by `animationProgressByType` instead of
|
||||
confidence: a further-along read replaces the kept one, an earlier one
|
||||
merges into it. A count read whose digits disagree with its slots, or a set
|
||||
merges into it. The count's end can fall between two sampled frames (a 2 fps
|
||||
live capture), so a set result read after the change lands takes its power
|
||||
from the power before it plus the change whenever an earlier read showed the
|
||||
old value (`carryOverByType`); the old value holds ~1.5s, the final one ~1s. A count read whose digits disagree with its slots, or a set
|
||||
result whose header doesn't describe a decided set of tiles (tiles still
|
||||
popping in), scores under the timeline floor. The result tiles center on
|
||||
the card (five games three over two, four two over two, three in one row
|
||||
|
||||
@@ -523,7 +523,7 @@ function onResult(
|
||||
action.action === "added" ? undefined : storedIds.get(action.replaced);
|
||||
// a run's trailing read only moves its time; the run's first read keeps the frame
|
||||
const frame = action.action === "extended" ? undefined : result.frame;
|
||||
const saving = persist(event, frame, stale);
|
||||
const saving = persist(action.event, frame, stale);
|
||||
persisting.add(saving);
|
||||
void saving.finally(() => persisting.delete(saving));
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ export async function startVodScan(
|
||||
? undefined
|
||||
: events.find((e) => sameEvent(e, action.replaced));
|
||||
const scanEvent: ScanEvent = {
|
||||
...event,
|
||||
...action.event,
|
||||
hasFrame: frame !== undefined,
|
||||
};
|
||||
if (frame) frames.set(scanEvent, frame);
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { ScannerLobby } from "../../../scanner-types";
|
||||
import type { Roi } from "../../canonical";
|
||||
import type { Mat } from "../../cv";
|
||||
import type { GlyphSet } from "../../glyphs";
|
||||
import type { GlyphSet, RecognizedChar } from "../../glyphs";
|
||||
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
|
||||
import { all, type MatchSteps } from "../../match-steps";
|
||||
import { closestBy } from "../../text";
|
||||
import { readTagBandSteps } from "../scoreboard/header";
|
||||
import { readTagBandGlyphsSteps, readTagBandSteps } from "../scoreboard/header";
|
||||
import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
|
||||
|
||||
export interface ParsedReplayHeader {
|
||||
@@ -31,6 +31,16 @@ export interface ParsedReplayHeader {
|
||||
|
||||
const MIN_MATCH_SCORE = 0.62;
|
||||
|
||||
type Meridiem = "AM" | "PM";
|
||||
|
||||
/** The clock's AM/PM letter reads as "o" (or a lookalike) whichever it is; the probe decides. */
|
||||
const MERIDIEM_LETTER_LOOKALIKES = /^[AaPpoO0n]$/;
|
||||
const CLOCK_DIGIT_LOOKALIKES = /^[0-9OoIl|S]$/;
|
||||
/** Lowest share of the letter's ink rows probed for legs. */
|
||||
const MERIDIEM_LEG_ROWS_FRACTION = 0.25;
|
||||
/** Recognizer's binarization (its default), so the probe sees the ink it segmented. */
|
||||
const MERIDIEM_INK_MIN = 150;
|
||||
|
||||
/**
|
||||
* Lifted-blacks captures raise the tag background to ~80-115 gray, above
|
||||
* readTagBand's default ceiling, truncating the band to a sliver. A failed snap
|
||||
@@ -80,7 +90,10 @@ function foldDigitLookalikes(s: string): string {
|
||||
return s.replace(/0/g, "o").replace(/[1|I]/g, "l");
|
||||
}
|
||||
|
||||
function parseTopBand(reading: string): TopBandParse {
|
||||
function parseTopBand(
|
||||
reading: string,
|
||||
probedMeridiem: Meridiem | null,
|
||||
): TopBandParse {
|
||||
let timestamp: string | null = null;
|
||||
let stage: StageId | null = null;
|
||||
let stageScore = 0;
|
||||
@@ -98,8 +111,7 @@ function parseTopBand(reading: string): TopBandParse {
|
||||
? reading.slice(reading.length - m[4]!.length)
|
||||
: reading;
|
||||
if (m) {
|
||||
// P's bowl can read as "o", folded to "0" above
|
||||
const meridiem = m[3] ? ` ${m[3].replace("0", "P")}` : "";
|
||||
const meridiem = m[3] ? ` ${probedMeridiem ?? m[3].replace("0", "P")}` : "";
|
||||
timestamp = `${m[1]!.replace(/ /g, "")} ${m[2]!.replace(/ /g, "")}${meridiem}`;
|
||||
}
|
||||
if (stageReading) {
|
||||
@@ -143,20 +155,21 @@ export function* parseReplayHeaderSteps(
|
||||
tagLeadInMax: bands.tagLeadInMax,
|
||||
tagColumnFraction: bands.tagColumnFraction,
|
||||
};
|
||||
const readTop = function* (): MatchSteps<ReturnType<typeof parseTopBand>> {
|
||||
let top = parseTopBand(
|
||||
yield* readTagBandSteps(gray, bands.top, topGlyphs, leadIn, speculative),
|
||||
const readTopBand = function* (tagDarkMax?: number) {
|
||||
const read = yield* readTagBandGlyphsSteps(
|
||||
gray,
|
||||
bands.top,
|
||||
topGlyphs,
|
||||
{ ...leadIn, tagDarkMax },
|
||||
speculative,
|
||||
probeMeridiem,
|
||||
);
|
||||
return parseTopBand(read.text, read.inspected);
|
||||
};
|
||||
const readTop = function* (): MatchSteps<ReturnType<typeof parseTopBand>> {
|
||||
let top = yield* readTopBand();
|
||||
if (top.stage === null) {
|
||||
const retry = parseTopBand(
|
||||
yield* readTagBandSteps(
|
||||
gray,
|
||||
bands.top,
|
||||
topGlyphs,
|
||||
{ ...leadIn, tagDarkMax: TAG_DARK_MAX_LIFTED },
|
||||
speculative,
|
||||
),
|
||||
);
|
||||
const retry = yield* readTopBand(TAG_DARK_MAX_LIFTED);
|
||||
if (retry.stageScore >= top.stageScore) top = retry;
|
||||
}
|
||||
return top;
|
||||
@@ -216,3 +229,41 @@ export function* parseReplayHeaderSteps(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A 12h clock's AM/PM, off the letter before the "M" that follows the time:
|
||||
* A stands on two legs, P on its stem alone, so the letter's bottom rows
|
||||
* carry ink right of its middle only for A. Both letters read as "o".
|
||||
*/
|
||||
function probeMeridiem(
|
||||
band: Mat,
|
||||
chars: readonly RecognizedChar[],
|
||||
): Meridiem | null {
|
||||
const colonAt = chars.findIndex((c) => c.char === ":");
|
||||
if (colonAt === -1) return null;
|
||||
const mAt = chars.findIndex(
|
||||
(c, i) =>
|
||||
i > colonAt + 1 &&
|
||||
c.char === "M" &&
|
||||
MERIDIEM_LETTER_LOOKALIKES.test(chars[i - 1]!.char) &&
|
||||
CLOCK_DIGIT_LOOKALIKES.test(chars[i - 2]!.char),
|
||||
);
|
||||
if (mAt === -1) return null;
|
||||
const letter = chars[mAt - 1]!;
|
||||
const { cols, data } = band;
|
||||
const middle = Math.floor((letter.x0 + letter.x1) / 2);
|
||||
const legRows = Math.max(
|
||||
1,
|
||||
Math.round((letter.y1 - letter.y0) * MERIDIEM_LEG_ROWS_FRACTION),
|
||||
);
|
||||
let rowsWithRightLeg = 0;
|
||||
for (let y = letter.y1 - legRows; y < letter.y1; y++) {
|
||||
for (let x = middle + 1; x < letter.x1; x++) {
|
||||
if (data[y * cols + x]! >= MERIDIEM_INK_MIN) {
|
||||
rowsWithRightLeg++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rowsWithRightLeg * 2 > legRows ? "AM" : "PM";
|
||||
}
|
||||
|
||||
@@ -126,16 +126,37 @@ export function* readTagBandSteps(
|
||||
options: TagBandOptions = {},
|
||||
speculative = false,
|
||||
): MatchSteps<string> {
|
||||
const read = yield* readTagBandGlyphsSteps(
|
||||
gray,
|
||||
band,
|
||||
glyphs,
|
||||
options,
|
||||
speculative,
|
||||
() => null,
|
||||
);
|
||||
return read.text;
|
||||
}
|
||||
|
||||
/** `readTagBandSteps` that also hands the trimmed band and its recognized glyphs to `inspect` before the band is freed. */
|
||||
export function* readTagBandGlyphsSteps<T>(
|
||||
gray: Mat,
|
||||
band: { x: number; y: number; w: number; h: number },
|
||||
glyphs: GlyphSet,
|
||||
options: TagBandOptions,
|
||||
speculative: boolean,
|
||||
inspect: (trimmed: Mat, chars: readonly RecognizedChar[]) => T,
|
||||
): MatchSteps<{ text: string; inspected: T | null }> {
|
||||
const trimmed = trimTagBand(gray, band, options);
|
||||
if (!trimmed) return "";
|
||||
if (!trimmed) return { text: "", inspected: null };
|
||||
const result = yield* recognizeTextSteps(
|
||||
trimmed,
|
||||
glyphs,
|
||||
{ ...TAG_RECOGNIZE_OPTIONS, ...options },
|
||||
speculative,
|
||||
);
|
||||
const inspected = inspect(trimmed, result.chars);
|
||||
trimmed.delete();
|
||||
return result.text.trim();
|
||||
return { text: result.text.trim(), inspected };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* in the splat and the power counts toward the new value for ~0.3s before
|
||||
* the card fades. A read only counts once the header agrees with a decided
|
||||
* set of tiles, and the timeline keeps the furthest-along read
|
||||
* (`xSetResultProgress`).
|
||||
* (`xSetResultProgress`), its power settled from the power before the change
|
||||
* when an earlier read showed it (`xSetResultCarryOver`).
|
||||
*/
|
||||
import type { RankedModeShort } from "~/modules/in-game-lists/types";
|
||||
import type { Mat } from "../../cv";
|
||||
@@ -97,6 +98,32 @@ export function xSetResultProgress(a: unknown, b: unknown): number {
|
||||
return Math.sign(delta * Math.sign(db.powerChange));
|
||||
}
|
||||
|
||||
/**
|
||||
* The data a further-along read keeps when it replaces `kept` in the
|
||||
* timeline: once the change lands, the new X Power is the power before it
|
||||
* plus the change, so a read caught mid-count (or with its power unread)
|
||||
* settles to that sum. A read power off the count between the two is left
|
||||
* as read.
|
||||
*/
|
||||
export function xSetResultCarryOver(kept: unknown, further: unknown): unknown {
|
||||
const before = kept as XSetResultData;
|
||||
const after = further as XSetResultData;
|
||||
if (
|
||||
before.powerChange !== null ||
|
||||
before.power === null ||
|
||||
after.powerChange === null ||
|
||||
before.results.length !== after.results.length
|
||||
) {
|
||||
return after;
|
||||
}
|
||||
const settled = Math.round((before.power + after.powerChange) * 10) / 10;
|
||||
const onCount =
|
||||
after.power === null ||
|
||||
Math.abs(after.power - before.power) + Math.abs(settled - after.power) <=
|
||||
Math.abs(after.powerChange) + POWER_EPSILON;
|
||||
return onCount ? { ...after, power: settled } : after;
|
||||
}
|
||||
|
||||
export function createXSetResultDetector(
|
||||
resources: ScoreboardResources,
|
||||
): Detector<XSetResultData> {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "../detectors/x-rank/set-count";
|
||||
import {
|
||||
X_SET_RESULT_EVENT_TYPE,
|
||||
xSetResultCarryOver,
|
||||
xSetResultProgress,
|
||||
} from "../detectors/x-rank/set-result";
|
||||
import { sameScoreboardMatch } from "./same-scoreboard";
|
||||
@@ -78,6 +79,12 @@ export interface TimelineOptions {
|
||||
* it; only reads level on the animation fall back to confidence.
|
||||
*/
|
||||
animationProgressByType: Record<string, (a: unknown, b: unknown) => number>;
|
||||
/**
|
||||
* per-type data a further-along read takes over from the read it replaces:
|
||||
* an earlier frame of the animation can show what the later one has moved
|
||||
* past (the X Power before the change).
|
||||
*/
|
||||
carryOverByType: Record<string, (kept: unknown, further: unknown) => unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
|
||||
@@ -128,6 +135,9 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
|
||||
[X_SET_RESULT_EVENT_TYPE]: xSetResultProgress,
|
||||
[X_RANK_POSITION_EVENT_TYPE]: xRankPositionProgress,
|
||||
},
|
||||
carryOverByType: {
|
||||
[X_SET_RESULT_EVENT_TYPE]: xSetResultCarryOver,
|
||||
},
|
||||
};
|
||||
|
||||
export type TimelineAction =
|
||||
@@ -180,18 +190,30 @@ export class TimelineBuilder {
|
||||
event.data,
|
||||
) ?? 0;
|
||||
if (progress < 0) return { action: "merged", into: near };
|
||||
if (progress > 0) {
|
||||
const carryOver = this.#options.carryOverByType[event.type];
|
||||
return this.#replace(
|
||||
near,
|
||||
carryOver
|
||||
? { ...event, data: carryOver(near.data, event.data) }
|
||||
: event,
|
||||
);
|
||||
}
|
||||
if (
|
||||
progress > 0 ||
|
||||
(event.confidence > near.confidence &&
|
||||
!this.#options.firstReadTypes.includes(event.type))
|
||||
event.confidence > near.confidence &&
|
||||
!this.#options.firstReadTypes.includes(event.type)
|
||||
) {
|
||||
this.#events[this.#events.indexOf(near)] = event;
|
||||
this.#events.sort((a, b) => a.t - b.t);
|
||||
return { action: "replaced", event, replaced: near };
|
||||
return this.#replace(near, event);
|
||||
}
|
||||
return { action: "merged", into: near };
|
||||
}
|
||||
|
||||
#replace(kept: DetectedEvent, event: DetectedEvent): TimelineAction {
|
||||
this.#events[this.#events.indexOf(kept)] = event;
|
||||
this.#events.sort((a, b) => a.t - b.t);
|
||||
return { action: "replaced", event, replaced: kept };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-state neighbours (by `t`) decide a sample's fate: none within the
|
||||
* window → added; already bracketed by two → merged (a late-arriving VoD
|
||||
|
||||
@@ -75,9 +75,10 @@
|
||||
"weaponId": 50
|
||||
}
|
||||
],
|
||||
"stageLabel": "Bluefin Depot"
|
||||
"stageLabel": "Bluefin Depot",
|
||||
"timestamp": "6/27/2026 10:43 PM"
|
||||
},
|
||||
"options": {
|
||||
"notes": "1080p VoD frame (InTheZone #52, t=11491.5) of the lobby's quick battle log, opened 16s after this game's results screen. players.4.stats and players.7.stats (deaths x07) once read 1: the rectified card's digits are softer than the atlas crops, so at the default binarization the 7's antialiased bar thins out and the ink penalty hands the segment to 1. The misread broke the match builder's history-screen fingerprint and the revisit became a duplicate match card. players.1.name once read R_O_B_Or: the 4x3 dots were too short for the dot templates (only '_' qualified) and the soft T lost to r on the ink penalty. players.2.name once read ι′ちごシロッフ: い's split strokes read ι + ′ (a raised mark on the baseline no longer blocks the merge), and プ's ring touches the bar through one antialiased row, so the mark probe now retries on the stroke cores. timestamp omitted: the screen shows 10:43PM on a 12h clock and the parse drops the PM."
|
||||
"notes": "1080p VoD frame (InTheZone #52, t=11491.5) of the lobby's quick battle log, opened 16s after this game's results screen. players.4.stats and players.7.stats (deaths x07) once read 1: the rectified card's digits are softer than the atlas crops, so at the default binarization the 7's antialiased bar thins out and the ink penalty hands the segment to 1. The misread broke the match builder's history-screen fingerprint and the revisit became a duplicate match card. players.1.name once read R_O_B_Or: the 4x3 dots were too short for the dot templates (only '_' qualified) and the soft T lost to r on the ink penalty. players.2.name once read ι′ちごシロッフ: い's split strokes read ι + ′ (a raised mark on the baseline no longer blocks the merge), and プ's ring touches the bar through one antialiased row, so the mark probe now retries on the stroke cores. timestamp (10:43PM on a 12h clock) once had its PM dropped; the meridiem letter reads as o for both A and P, now told apart by the letter probe."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"event": "QuickScoreboardBattleLog",
|
||||
"data": {
|
||||
"lobby": "X",
|
||||
"mode": "RM",
|
||||
"stage": 16,
|
||||
"stageLabel": "Humpback Pump Track",
|
||||
"timestamp": "10/16/2026 2:01 AM",
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"players": [
|
||||
{
|
||||
"name": "かめちゃん.★",
|
||||
"weaponId": 241,
|
||||
"paint": 277,
|
||||
"ka": 7,
|
||||
"d": 0,
|
||||
"s": 1
|
||||
},
|
||||
{
|
||||
"name": "ニディ",
|
||||
"weaponId": 8001,
|
||||
"paint": 412,
|
||||
"ka": 2,
|
||||
"d": 0,
|
||||
"s": 0
|
||||
},
|
||||
{
|
||||
"name": "Bowser",
|
||||
"weaponId": 7011,
|
||||
"paint": 382,
|
||||
"ka": 2,
|
||||
"d": 0,
|
||||
"s": 1
|
||||
},
|
||||
{
|
||||
"name": "ゆう",
|
||||
"weaponId": 5010,
|
||||
"paint": 308,
|
||||
"ka": 2,
|
||||
"d": 0,
|
||||
"s": 1
|
||||
},
|
||||
{
|
||||
"name": "エナスタ GIRL",
|
||||
"weaponId": 1020,
|
||||
"paint": 390,
|
||||
"ka": 0,
|
||||
"d": 2,
|
||||
"s": 1
|
||||
},
|
||||
{
|
||||
"name": "マンポマンポママンポ",
|
||||
"weaponId": 211,
|
||||
"paint": 153,
|
||||
"ka": 0,
|
||||
"d": 3,
|
||||
"s": 0
|
||||
},
|
||||
{
|
||||
"name": "'BM' ψそうまψ",
|
||||
"weaponId": 7030,
|
||||
"paint": 193,
|
||||
"ka": 0,
|
||||
"d": 2,
|
||||
"s": 0
|
||||
},
|
||||
{
|
||||
"name": "△Dr. Lion",
|
||||
"weaponId": 8001,
|
||||
"paint": 192,
|
||||
"ka": 0,
|
||||
"d": 3,
|
||||
"s": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"skipFields": [
|
||||
"players.0.name",
|
||||
"players.6.name"
|
||||
],
|
||||
"notes": "1080p capture of the lobby's quick battle log while matchmaking (X Battle, the POV player on the losing side), opened minutes after a game whose results screen was never shown. timestamp read 2:01 PM: the AM marker read as PM, the recording time landed 12h off and the history screen became a duplicate match card instead of completing the game. players.0.name shows かめちゃん,★ (comma, ASCII or fullwidth unclear) and players.6.name's quote marks around BM may be curly; both skipped. Weapons from the prefilled read, matching the icons."
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 962 KiB |
@@ -78,6 +78,44 @@ describe("TimelineBuilder X Battle cards", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "caught mid-count", power: 2411.3 },
|
||||
{ why: "unread", power: null },
|
||||
])(
|
||||
"settles an X Power $why to the power before the change plus the change",
|
||||
({ power }) => {
|
||||
const timeline = new TimelineBuilder();
|
||||
timeline.push(result(0, { powerChange: null, power: 2373.8 }));
|
||||
timeline.push(result(0.5, { powerChange: null, power: 2373.8 }));
|
||||
timeline.push(result(1, { powerChange: 75, power }));
|
||||
|
||||
expect(timeline.events.map((e) => e.data)).toMatchObject([
|
||||
{ powerChange: 75, power: 2448.8 },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the settled X Power over a later read still counting", () => {
|
||||
const timeline = new TimelineBuilder();
|
||||
timeline.push(result(0, { powerChange: null, power: 2752.4 }));
|
||||
timeline.push(result(0.5, { powerChange: -29.2, power: 2752.4 }));
|
||||
|
||||
const action = timeline.push(
|
||||
result(1, { powerChange: -29.2, power: 2742.6 }),
|
||||
);
|
||||
|
||||
expect(action.action).toBe("merged");
|
||||
expect(timeline.events[0]!.data).toMatchObject({ power: 2723.2 });
|
||||
});
|
||||
|
||||
test("leaves an X Power read off the count from the power before the change as read", () => {
|
||||
const timeline = new TimelineBuilder();
|
||||
timeline.push(result(0, { powerChange: null, power: 2373.8 }));
|
||||
timeline.push(result(1, { powerChange: 75, power: 2911.3 }));
|
||||
|
||||
expect(timeline.events[0]!.data).toMatchObject({ power: 2911.3 });
|
||||
});
|
||||
|
||||
test("ignores an X Power read further away than the whole change", () => {
|
||||
const timeline = new TimelineBuilder();
|
||||
timeline.push(result(1.4, { powerChange: -29.2, power: 2728.0 }));
|
||||
|
||||
5
changelog/2026-10-17-scanner-battle-log-am-time.md
Normal file
5
changelog/2026-10-17-scanner-battle-log-am-time.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: scanner
|
||||
type: bug
|
||||
---
|
||||
Fixed the scanner adding a duplicate card when the battle log of a game played in the AM is opened after a game with no results screen
|
||||
5
changelog/2026-10-17-scanner-x-power-count.md
Normal file
5
changelog/2026-10-17-scanner-x-power-count.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: scanner
|
||||
type: bug
|
||||
---
|
||||
Fixed the scanner sometimes showing an X Power that was still counting up or down after a set
|
||||
Reference in New Issue
Block a user