Preserve terminal feedback while coalescing repaints

This commit is contained in:
Storm Dragon committed 2026-11-04 12:22:11 -05:00
1 parent 8c26aad128
commit e222d336a5
13 files changed
+321 -39

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go.
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
- [XTerm handoff](docs/testing/xterm-handoff.md): terminal reader keys, lock state, speech interruption, and verification.
- [Graphical terminals](docs/testing/graphical-terminals.md): repaint coalescing, editing feedback, title animation, and verification.
- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification.
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
+58
View File
@@ -0,0 +1,58 @@
# Graphical terminal regression checks
Terminal scrollback removal stays silent. In-place delete/insert repaints of
the same region coalesce to the latest insertion for up to 500 milliseconds;
continuous output retains that first deadline. Different terminals and regions
keep their own feedback, and appended output flushes an earlier held repaint
in order. Short updates such as `OK` and counts still speak. Backspace and
Delete feedback on the caret line remains available. Moving focus to another
control cancels the held repaint.
Typing in a focused terminal can establish flat-review focus if the reader
missed its initial focus event. A different control's stale focused bit must
not replace known review focus.
Animated window-title frames share a normalized repeat key for three seconds
after their last occurrence. History belongs to that window and survives a
workspace round trip; another window's identical title can still announce.
Explicit title reading remains available. See [title checks](../../tests/window_titles.md).
Run from the repository root:
```sh
go test ./internal/app -run 'Terminal|Title|Caret' -count=1
go build -o /tmp/cthulhu-terminal-review ./cmd/cthulhu
CTHULHU_BINARY=/tmp/cthulhu-terminal-review python3 tests/native_terminal.py
```
The native fixture uses a private X11 display, accessibility bus, shell and
speech protocol recorder. It checks scrolling, command output, Backspace,
Ctrl+W, Delete, history and flat review without replacing the desktop reader.
For live acceptance with a rebuilt reader:
1. In a VTE terminal, run commands that scroll past the visible rows. Confirm
each command's output speaks without replaying removed prompts.
2. Type text, use Backspace, Ctrl+W, Left and Delete, then recall command
history. Check speech and Braille against the edited line.
3. Read current and previous lines with flat review. Repeat after starting
the reader while that terminal already has focus, without switching away.
4. Use a TUI that continuously rewrites a status region. Confirm its latest
status speaks during the activity, including short counts or `OK`. Check
separate regions, appended output, and two terminal panes. Move focus to
another control and confirm an old repaint does not follow it.
5. Check animated titles, meaningful title changes, workspace round trips,
and two windows with identical titles. Check explicit title reading and
disabled automatic title changes. Confirm XTerm still delegates input to
the terminal reader and desktop controls resume normally on return.
Wayland Shift+digit heading navigation uses the default libxkbcommon keymap
(`XKB_DEFAULT_LAYOUT`, variant and options). These must match the compositor;
KeyboardMonitor does not expose its active keymap or group. Ambiguous shifted
symbols in multiple layouts are not registered as additional grabs. Verify
Shift+1 through Shift+6, Alt+Shift+digit heading lists, plain typing, learn mode,
and releasing Shift before the digit on the actual compositor. Private D-Bus
tests establish protocol dispatch, not physical key suppression.
Automated checks do not establish installed-reader, audible speech, physical
keyboard or physical Braille acceptance.
+20 -14
View File
@@ -254,6 +254,17 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
if autocompleteConsumed {
return
}
flushTerminalRepaint := func() {
if state.pending.terminalRepaint.event.Type == "" {
return
}
pending, _ := state.pending.terminalRepaint.take()
if is_trivial_terminal_repaint(pending) {
logger.Debug("dropped trivial terminal repaint fragment", "role", pending.Source.Role)
return
}
speak(pending)
}
replacedDeletion := false
if state.pending.terminalDeletion.event.Type != "" && event.Type != atspi.CaretEvent {
pendingDeletion := state.pending.terminalDeletion.event
@@ -271,8 +282,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
replacedDeletion = true
state.pending.terminalDeletion.clear()
logger.Debug("coalesced terminal deletion with following insertion", "role", event.Source.Role)
} else if !(event.Type == atspi.TextDeletedEvent && sameTerminal && !userDeletion) {
// Another deletion from the same terminal is part of the same
} else if !(event.Type == atspi.TextDeletedEvent && sameTerminal && !userDeletion &&
(should_ignore_terminal_scrollback(event) || terminalReplacementOverlap(pendingDeletion, event))) {
// An overlapping deletion from the same terminal is part of the same
// repaint burst. It is handled below: scroll shifts return
// early, anything else replaces the pending deletion. Either
// way the pending deletion must not flush early, or the burst
@@ -308,6 +320,11 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
return
}
if event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" {
if state.pending.terminalRepaint.event.Type != "" &&
!should_ignore_terminal_scrollback(event) &&
(is_user_terminal_deletion(event, intent) || !terminalReplacementOverlap(state.pending.terminalRepaint.event, event)) {
flushTerminalRepaint()
}
if should_ignore_terminal_scrollback(event) {
logger.Debug("skipped terminal scrollback deletion", "role", event.Source.Role)
return
@@ -323,23 +340,12 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
// briefly so a newer rewrite of the same region replaces it before
// either speaks. A rewrite of a different region, or appended output
// from the same terminal, first flushes the held repaint in order.
flushTerminalRepaint := func() {
if state.pending.terminalRepaint.event.Type == "" {
return
}
pending, _ := state.pending.terminalRepaint.take()
if is_trivial_terminal_repaint(pending) {
logger.Debug("dropped trivial terminal repaint fragment", "role", pending.Source.Role)
return
}
speak(pending)
}
if is_latest_wins_terminal_repaint(event, intent, replacedDeletion) {
if state.pending.terminalRepaint.event.Type != "" &&
!terminalReplacementOverlap(state.pending.terminalRepaint.event, event) {
flushTerminalRepaint()
}
state.pending.terminalRepaint.schedule(event, input.NoIntent, terminalRepaintDebounce)
state.pending.terminalRepaint.coalesce_terminal_repaint(event)
logger.Debug("queued latest-wins terminal repaint", "role", event.Source.Role)
if autocompleteRequest.Text != "" {
speech.Enqueue(autocompleteRequest)
+1
View File
@@ -29,6 +29,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
// delete input keeps a background terminal's own output churn
// from claiming review focus.
if objectReview != nil && event.Source.Role == "terminal" && event.Source.Focused &&
(!state.focusedID.Valid() || state.focusedID == event.Source.ID()) &&
(intent == input.TypingIntent || intent == input.DeleteIntent) {
objectReview.SyncFocus(event.Source.ID())
if !state.focusedID.Valid() {
+7
View File
@@ -187,6 +187,13 @@ func TestTerminalCaretSeedsReviewFocusWithoutFocusEvent(t *testing.T) {
if got := worker.Focus(); got != terminal.ID() {
t.Fatalf("review focus after unfocused caret = %+v, want %+v", got, terminal.ID())
}
// Another widget in the same application can retain a stale focused
// bit. Its caret must not move review away from the known focus.
other.Focused = true
handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, other), input.Activity{}, input.TypingIntent)
if got := worker.Focus(); got != state.focusedID {
t.Fatalf("stale focused bit split review and input ownership: %+v != %+v", got, state.focusedID)
}
}
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
+3
View File
@@ -47,6 +47,9 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
state.pending.browserButtonName.clear()
}
focusID := event.Source.ID()
if isFocusGainEvent(event) && state.pending.terminalRepaint.event.Source.ID() != focusID {
state.pending.terminalRepaint.clear()
}
if isFocusGainEvent(event) {
state.focusCaretID = focusID
state.focusCaretSequence = activity.Sequence
+17 -6
View File
@@ -7,7 +7,6 @@ package app
import (
"strings"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
@@ -56,13 +55,11 @@ func is_latest_wins_terminal_repaint(event atspi.Event, intent input.Intent, rep
return true
}
// is_trivial_terminal_repaint identifies a held repaint whose payload is
// only the few cells that changed in a ticking status (timer digits such
// as "40"). Uttered alone it has no context; a fuller repaint of the same
// region replaces it before the flush if one follows.
// Blank repaints carry no speech. Short nonblank updates can still be
// meaningful ("OK", a count, or one Unicode character) and must survive.
func is_trivial_terminal_repaint(event atspi.Event) bool {
return event.Type == atspi.TextInsertedEvent && event.Source.Role == "terminal" &&
utf8.RuneCountInString(strings.TrimSpace(event.Payload)) <= 2
strings.TrimSpace(event.Payload) == ""
}
// terminalReplacementOverlap reports whether an insertion rewrites any of
@@ -71,7 +68,21 @@ func is_trivial_terminal_repaint(event atspi.Event) bool {
// replacement insertion moves the insertion's offset, and a repainted
// region is routinely rewritten at a neighboring offset.
func terminalReplacementOverlap(deletion, insertion atspi.Event) bool {
if deletion.Source.Role != "terminal" || insertion.Source.Role != "terminal" ||
deletion.Source.Path == "" || deletion.Source.ID() != insertion.Source.ID() {
return false
}
deletionEnd := deletion.Detail1 + max(deletion.TextChangeLength, 1)
insertionEnd := insertion.Detail1 + max(insertion.TextChangeLength, 1)
return insertion.Detail1 < deletionEnd && deletion.Detail1 < insertionEnd
}
// Preserve the first deadline so a continuously redrawn status still speaks
// its latest value. The event loop owns both the event and the timer.
func (pending *pendingPresentation) coalesce_terminal_repaint(event atspi.Event) {
if pending.event.Type == "" {
pending.schedule(event, input.NoIntent, terminalRepaintDebounce)
return
}
pending.event = event
}
+114 -6
View File
@@ -323,7 +323,7 @@ func TestProcessEventsTerminalRepaintsInDifferentRegionsBothSpeak(t *testing.T)
}
}
func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
func TestProcessEventsPreservesShortTerminalRepaints(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
@@ -338,11 +338,16 @@ func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
terminal.TextCaretKnown = true
terminal.BrailleLineStart = 200
terminal.BrailleCaret = 205
repaintSequence(adapter, terminal, 100, "40")
select {
case text := <-speech.spoken:
t.Fatalf("trivial repaint fragment was presented as %q", text)
case <-time.After(2 * terminalRepaintDebounce):
for _, expected := range []string{"40", "OK", "猫"} {
repaintSequence(adapter, terminal, 100, expected)
select {
case text := <-speech.spoken:
if text != expected {
t.Fatalf("short repaint = %q, want %q", text, expected)
}
case <-time.After(3 * time.Second):
t.Fatalf("short repaint %q was lost", expected)
}
}
repaintSequence(adapter, terminal, 100, "Thinking (4s)")
select {
@@ -576,3 +581,106 @@ func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.
t.Fatal("event loop did not stop")
}
}
// Run the real event handler synchronously so coalescing assertions do not
// depend on a scheduler processing an entire burst before a timer fires.
func terminalHandlerFixture(t *testing.T) (*accessibilityEventHandler, *[]string) {
t.Helper()
state := &eventLoopState{}
t.Cleanup(state.pending.clear)
spoken := []string{}
handler := &accessibilityEventHandler{
state: state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
syncForeground: func() {}, webScripts: &webScriptController{},
speak: func(event atspi.Event, _ ...input.Intent) { spoken = append(spoken, event.Payload) },
}
handler.flushTerminalDeletion = func() {
event, _ := state.pending.terminalDeletion.take()
handler.speak(event)
}
return handler, &spoken
}
func TestTerminalRepaintsKeepTerminalIdentityAndFirstDeadline(t *testing.T) {
for _, differentTerminal := range []bool{false, true} {
t.Run(map[bool]string{false: "same terminal", true: "different bus"}[differentTerminal], func(t *testing.T) {
handler, spoken := terminalHandlerFixture(t)
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
repaint := func(text string) {
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text)
handler.handle(deletion)
handler.handle(insertion)
}
repaint("First status")
firstTimer := handler.state.pending.terminalRepaint.timer
if differentTerminal {
terminal.BusName = ":1.11"
}
repaint("Second status")
pending := &handler.state.pending.terminalRepaint
if differentTerminal {
if len(*spoken) != 1 || (*spoken)[0] != "First status" {
t.Fatalf("different terminal discarded earlier repaint: %v", *spoken)
}
} else if pending.timer != firstTimer {
t.Fatal("continuous repaint postponed the first deadline")
}
event, _ := pending.take()
if event.Payload != "Second status" {
t.Fatalf("latest repaint = %q", event.Payload)
}
})
}
}
func TestTerminalSeparateDeletionsPreserveEarlierFeedback(t *testing.T) {
handler, spoken := terminalHandlerFixture(t)
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
first := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "First deletion")
first.Detail1 = 100
second := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Second deletion")
second.Detail1 = 300
handler.handle(first)
handler.handle(second)
if len(*spoken) != 1 || (*spoken)[0] != first.Payload {
t.Fatalf("unrelated deletion discarded earlier feedback: %v", *spoken)
}
if handler.state.pending.terminalDeletion.event.Payload != second.Payload {
t.Fatal("later deletion was lost")
}
}
func TestTerminalHeldRepaintPreservesOrderAndRetiresOnFocusChange(t *testing.T) {
for _, next := range []string{"append", "deletion", "focus"} {
t.Run(next, func(t *testing.T) {
handler, spoken := terminalHandlerFixture(t)
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Latest status")
handler.handle(deletion)
handler.handle(insertion)
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Appended output")
event.Detail1 = 300
switch next {
case "deletion":
event.Type, event.Payload = atspi.TextDeletedEvent, "Later deletion"
case "focus":
event = atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.10", Path: "/entry", Role: "text", Focused: true})
}
handler.handle(event)
if handler.state.pending.terminalRepaint.event.Type != "" {
t.Fatal("old repaint remains pending")
}
if next == "focus" {
for _, text := range *spoken {
if text == "Latest status" {
t.Fatal("old terminal spoke after focus moved to another control")
}
}
} else if len(*spoken) == 0 || (*spoken)[0] != "Latest status" {
t.Fatalf("later output overtook pending repaint: %v", *spoken)
}
})
}
}
+30 -8
View File
@@ -33,6 +33,12 @@ type pendingWindowTitle struct {
inputSequence uint64
}
type windowTitleRepeatKey struct {
window atspi.ObjectID
nativeWindow, titleWindow uint64
text string
}
// windowTitleController is owned by the accessibility event loop. Discovery
// stays in the native adapter; this controller owns coalescing, preferences,
// and duplicate suppression across AT-SPI and X11.
@@ -47,7 +53,7 @@ type windowTitleController struct {
inputSource any
lastNative atspi.ForegroundTitle
lastTitle string
recent map[string]time.Time
recent map[windowTitleRepeatKey]time.Time
application string
pending *pendingWindowTitle
timer *time.Timer
@@ -189,8 +195,8 @@ func (titles *windowTitleController) nativeChanged() {
// Keep the repeat history: a native sampler can re-report the
// same client as a switch (new epoch or title-window id) on
// every title sample, and clearing here would defeat animated
// title suppression. Genuine window switches clear it through
// AT-SPI activation instead.
// title suppression. History is scoped to the window identity,
// so another window can still announce the same title.
if titles.logger != nil {
titles.logger.Debug("native window title switch",
"epoch", latest.Epoch, "window", latest.Title.Window,
@@ -244,9 +250,25 @@ func animatedWindowTitleKey(title string) string {
// the repeat window. A suppressed sighting refreshes its entry, so a
// running animation stays suppressed instead of breaking through once per
// window; it expires shortly after the animation stops.
func (titles *windowTitleController) repeatedWithinWindow(text string, at time.Time) bool {
key := animatedWindowTitleKey(text)
if key == "" {
func (titles *windowTitleController) repeatedWithinWindow(pending pendingWindowTitle, at time.Time) bool {
key := windowTitleRepeatKey{window: pending.window, text: animatedWindowTitleKey(pending.text)}
if key.text == "" {
return false
}
// Correlate AT-SPI and native samples through the same native client
// when available. Do not include its epoch: returning to that client
// should retain its animation history.
native := pending.native
if native.Window == 0 && titles.source != nil {
latest := titles.source.ForegroundTitle()
if latest.Epoch == pending.epoch {
native = latest.Title
}
}
if native.Window != 0 {
key.window = atspi.ObjectID{}
key.nativeWindow, key.titleWindow = native.Window, native.TitleWindow
} else if !key.window.Valid() {
return false
}
if at.IsZero() {
@@ -258,7 +280,7 @@ func (titles *windowTitleController) repeatedWithinWindow(text string, at time.T
}
}
if titles.recent == nil {
titles.recent = make(map[string]time.Time)
titles.recent = make(map[windowTitleRepeatKey]time.Time)
}
_, repeated := titles.recent[key]
titles.recent[key] = at
@@ -392,7 +414,7 @@ func (titles *windowTitleController) flush() {
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
return
}
if pending.changed && titles.repeatedWithinWindow(pending.text, pending.observedAt) {
if pending.changed && titles.repeatedWithinWindow(*pending, time.Now()) {
if titles.logger != nil {
titles.logger.Debug("suppressed repeated window title",
"key", animatedWindowTitleKey(pending.text), "recent", len(titles.recent))
+43
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"path/filepath"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
@@ -746,3 +747,45 @@ func TestWineReviewTitlesUseNativeApplicationPreferences(t *testing.T) {
t.Fatal("Wine title ignored its application preference")
}
}
func TestTitleRepeatHistoryIsScopedToWindow(t *testing.T) {
titles, queue, changed := titleControllerFixture(t)
changed.Source.Name = "Build finished"
titles.accessible(changed)
titles.flush()
other := ownerEvent(atspi.WindowActivatedEvent, ":1.51", "/window", "/window")
other.Source.Name, other.Source.Role = "Original", "frame"
titles.state.owner.activate(other)
titles.activated(other)
other.Type, other.Source.Name = atspi.AccessibleNameEvent, "Build finished"
titles.accessible(other)
titles.flush()
if len(queue.requests) != 2 {
t.Fatalf("another window's title was suppressed: %+v", queue.requests)
}
}
func TestTitleRepeatHistoryRefreshesAndExpires(t *testing.T) {
titles, _, changed := titleControllerFixture(t)
pending := pendingWindowTitle{window: changed.Source.ID(), text: "⠋ Terminal"}
start := time.Now()
if titles.repeatedWithinWindow(pending, start) {
t.Fatal("first title suppressed")
}
pending.text = "⠙ Terminal"
if !titles.repeatedWithinWindow(pending, start.Add(2*time.Second)) ||
!titles.repeatedWithinWindow(pending, start.Add(4*time.Second)) {
t.Fatal("active animation history expired")
}
if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) {
t.Fatal("inactive animation history did not expire")
}
pending.native = xorg.WindowTitle{Window: 10, TitleWindow: 11}
if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) {
t.Fatal("native window inherited AT-SPI-only history")
}
pending.native.TitleWindow = 12
if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) {
t.Fatal("another native child inherited title history")
}
}
+7 -4
View File
@@ -61,8 +61,8 @@ func lower_symbol(symbol uint32) uint32 { return uint32(C.xkb_keysym_to_lower(C.
// registration and event handling translate through shift pairs taken from
// the default XKB keymap (the XKB_DEFAULT_* environment, else libxkbcommon
// defaults). The monitor interface does not expose the compositor's keymap;
// when its layout differs from that keymap, shifted digit and punctuation
// chords keep the previous pass-through behavior instead of misfiring.
// shifted digit and punctuation chords therefore require these defaults to
// match the compositor's layout. The protocol cannot verify that match.
func shifted_symbol(symbol uint32) uint32 {
return uint32(C.xkb_keysym_to_upper(C.xkb_keysym_t(symbol)))
}
@@ -183,8 +183,11 @@ func make_grabs(configuration keyboardConfiguration, policy keyboardPolicy) ([]u
unique[keyGrab{shifted_symbol(chord.Symbol), mask}] = true
}
if mask&1 != 0 {
for _, symbol := range loadShiftPairs().shifted[chord.Symbol] {
unique[keyGrab{symbol, mask}] = true
pairs := loadShiftPairs()
for _, symbol := range pairs.shifted[chord.Symbol] {
if pairs.unshifted[symbol] == chord.Symbol {
unique[keyGrab{symbol, mask}] = true
}
}
}
}
+19
View File
@@ -214,6 +214,25 @@ func TestBrowseShiftedDigitHeadingLevels(t *testing.T) {
}
}
func TestAmbiguousShiftPartnersAreNotGrabbed(t *testing.T) {
// US Shift+apostrophe and GB Shift+2 both produce a double quote.
// Without the active group, the monitor cannot route it reliably.
pairs := build_shift_pairs("us,gb")
if _, ok := pairs.unshifted['"']; ok {
t.Fatal("ambiguous double quote has a reverse mapping")
}
original := loadShiftPairs
loadShiftPairs = func() shiftPairs { return pairs }
t.Cleanup(func() { loadShiftPairs = original })
_, grabs, _ := make_grabs(keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}},
keyboardPolicy{enabled: true, browse: true})
for _, grab := range grabs {
if grab.Symbol == '"' && grab.Modifiers&1 != 0 {
t.Fatalf("ambiguous shifted symbol grabbed: %+v", grab)
}
}
}
func TestLearnModeAndSecureReset(t *testing.T) {
owner := test_owner(t, input.LaptopKeyboardLayout, "Insert")
send_key(t, owner, 0xff63, 118, 0, false)
+1 -1
View File
@@ -22,7 +22,7 @@ import "strings"
const Name = "Cthulhu"
const Version = "2026.11.04"
const Channel = "testing"
const Channel = "hunter"
var Build = Version