Skip to content

Moonwatch Design History

Mahtra edited this page Jul 28, 2026 · 1 revision

Moonwatch Design History and Changelog

This is a historical design record and changelog, not a description of current behavior. It captures how moonwatch.lic evolved from a Firebase-dependent script into a self-contained offline engine, including the full version history back to v2.0. Several early sections (the "Math First, Firebase Second" design, Firebase writes, the collaborative sync model, the Firebase data format and guards) describe the Firebase era, which was removed in v4.0 -- there is no longer any network dependency, shared database, or correct argument. For how the current engine works, see the Technical Reference; for installation and usage, see the user guide. Where a section below is superseded, it is retained for provenance.

Overview

This document originally described the redesign of moonwatch.lic to use epoch-based mathematical calculations for moon position tracking, replacing the previous Firebase-dependent approach. That redesign is complete and has since gone considerably further (see the Changelog).

Span: December 2024 - July 2026 (v2.0 through v4.5.0) Current status: offline closed-form engine; Firebase fully removed in v4.0.


Problem Statement

The original moonwatch.lic had several limitations:

  1. Inaccurate constants - Duration values were rounded to whole minutes:

    Settings['rise']['katamba'] = 174 * 60  # 10,440 sec (actual: 10,485 sec)
    Settings['set']['katamba'] = 177 * 60   # 10,620 sec (actual: 10,605 sec)
  2. Firebase dependency - Required Firebase data on startup; couldn't work offline

  3. Frequent polling - Read from Firebase every few minutes, causing unnecessary network traffic

  4. Cold start problem - If no one had observed a moon event recently, predictions were stale


Analysis

Moon Period Data (moondata.2024)

Analyzed 2024 moon event logs to derive precise constants:

Moon Full Cycle (sec) Visible Duration (sec) Hidden Duration (sec)
Katamba 21,084 10,608 10,476
Xibar 20,852 10,482 10,370
Yavash 21,133 10,632 10,501

Reference Implementation (Genie TimeTracker)

Compared with Genie4's TimeTracker plugin which uses epoch-based calculations:

// TimeCalc.cs constants (very close to our analysis)
private const long _katambaCycle = 21088;
private const long _xibarCycle = 20848;
private const long _yavashCycle = 21130;

Key insight: TimeTracker calculates moon position mathematically from a known epoch point, making it accurate immediately without external data.


Design Decisions

1. Math First, Firebase Second

Primary: Calculate moon positions using epoch + cycle constants Secondary: Use Firebase as a correction/sync layer (hourly)

Benefits:

  • Accurate immediately on startup
  • Works offline
  • Reduces Firebase reads from every 4-10 min to hourly
  • Self-correcting when events are observed

2. Backward Compatibility

The timer field must remain in minutes because 6+ scripts depend on it:

  • dependency.lic
  • gate.lic
  • combat-trainer.lic
  • autocontingency.lic
  • mm.lic

New fields added for second-precision:

  • visible (boolean)
  • timer_seconds (integer)

3. Firebase Writes are Opt-In

To prevent 100 players writing to Firebase simultaneously when they all see the same moon event:

  • share argument or moonwatch_share setting required
  • Random 0-5 second delay before write
  • Check-before-write: skip if event already recorded within 60 seconds

4. Collaborative Sync Model

Firebase sync can show small discrepancies (e.g., "+10s") even when you're sharing data. This is expected because:

Firebase aggregates data from multiple players. When you sync on startup, you're comparing your epoch-based calculation against what someone else observed and uploaded.

Sources of variance between observers:

Source Explanation Status
Network latency Game sends moon event text at slightly different times per client Inherent
Timestamp capture XMLData.server_time comes from the previous prompt, not the exact event moment Fixed in v2.8 - now refreshes timestamp via time command
Constant drift Epoch values have inherent error; small drift accumulates over days/weeks Mitigated by self-correction

Example timeline:

14:05:00 - Script starts, syncs from Firebase
         - Firebase has data from ANOTHER player
         - Their observation differs from our model by 10s
         - We adjust: "xibar offset synced from Firebase: +10s"

16:15:25 - WE observe "Xibar sets"
         - No correction needed (model already aligned from sync)
         - We upload to Firebase for others

Key insight: Small discrepancies (under 60s) are normal variance between observers. Large discrepancies (hundreds of seconds) indicate the epoch constants need recalibration.


Implementation

Moon Constants

# Re-centered 2026-05-20 from 3,755 events over 89 days (2 characters)
MOON_CONSTANTS = {
  'katamba' => {
    epoch: 1_771_558_712,   # Re-centered: 1771558797 - 85s (recent offset mean)
    cycle: 21_089,          # OLS: 21088.67s, quantized 21060/21120 387/369 (n=756)
    visible: 10_602         # Observed: 10560/10620 131/350 (n=481), mean 10603.7
  },
  'xibar' => {
    epoch: 1_771_560_091,   # Re-centered: 1771560080 + 11s (recent offset mean)
    cycle: 20_848,          # OLS: 20848.19s, quantized 20820/20880 412/377 (n=789)
    visible: 10_482         # Observed: 10440/10500 153/359 (n=512), mean 10482.1
  },
  'yavash' => {
    epoch: 1_771_554_968,   # Re-centered: 1771555053 - 85s (recent offset mean)
    cycle: 21_130,          # OLS: 21129.63s, quantized 21120/21180 613/129 (n=742)
    visible: 10_624         # Observed: 10620/10680 454/30 (n=484), mean 10623.7
  }
}.freeze

Note: The original Genie epochs (49623621, etc.) did not align with Lich's XMLData.server_time. These epochs were recalibrated from Firebase events on 2024-12-24 to match Lich's time system.

Core Calculation

def calculate_moon_position(moon, game_time = nil)
  game_time ||= XMLData.server_time rescue Time.now.to_i
  constants = MOON_CONSTANTS[moon]
  offset = @moon_offsets[moon] || 0

  adjusted_time = game_time + offset
  position = (adjusted_time - constants[:epoch]) % constants[:cycle]

  is_visible = position < constants[:visible]

  if is_visible
    seconds_until_set = constants[:visible] - position
    { visible: true, seconds: seconds_until_set.to_i, event: 'set' }
  else
    seconds_until_rise = constants[:cycle] - position
    { visible: false, seconds: seconds_until_rise.to_i, event: 'rise' }
  end
end

Self-Correction

When a moon event is observed in game, compare prediction to reality:

def moon_change(moon, is_up)
  game_time = XMLData.server_time rescue Time.now.to_i
  predicted = calculate_moon_position(moon, game_time)
  position = (game_time + offset - constants[:epoch]) % constants[:cycle]

  if predicted[:visible] != is_up
    # We were completely wrong about visibility
    discrepancy = predicted[:seconds]
    $moon_offsets[moon] += discrepancy
    CharSettings["#{moon}_offset"] = $moon_offsets[moon]
  else
    # Drift detection: prediction matched, but timing was slightly off
    drift = is_up ? position : (position - constants[:visible])

    # Correct any drift - observations are ground truth
    if drift != 0
      $moon_offsets[moon] -= drift
      CharSettings["#{moon}_offset"] = $moon_offsets[moon]
    end

    # Auto-reset if offset grows too large
    if $moon_offsets[moon].abs > 1800
      $moon_offsets[moon] = 0
      CharSettings["#{moon}_offset"] = 0
    end
  end
end

Drift Detection (v2.2)

The original correction logic only fixed predictions when visibility was wrong at event time. This missed a common case: if we predicted the rise 2 minutes early, at the actual event time our model shows the moon as already up, so predicted[:visible] == is_up == true and no correction happened.

Example scenario:

  • Model predicts Yavash rises at 14:00:00
  • User sees "Yavash slowly rises" at 14:02:00
  • At 14:02:00, model says: visible=true (moon has been "up" for 2 minutes)
  • Old logic: predicted[:visible] == is_up → no correction
  • New logic: calculates drift = 120 seconds, applies negative correction

Drift calculation:

  • Rise event: drift = current position in cycle (should be 0)
  • Set event: drift = current position - visible_duration (should be 0)

Correction:

  • All observed drift is corrected immediately (v2.10)
  • Positive drift = predicted early, negative drift = predicted late
  • Observations are ground truth in pure-observation model; no threshold needed

Offset Storage (v2.6)

Offsets are stored in CharSettings (per-character, not shared):

$moon_offsets = {
  'katamba' => CharSettings['katamba_offset'] || 0,
  'xibar'   => CharSettings['xibar_offset'] || 0,
  'yavash'  => CharSettings['yavash_offset'] || 0
}
$sun_offset = CharSettings['sun_offset'] || 0

Benefits of per-character storage:

  • Quilsilgas's corrections don't affect Mahtra
  • New alts start fresh with offset=0
  • Bad Firebase sync on one character doesn't corrupt others
  • Each character's observations are independent

Auto-Reset (v2.6)

Superseded. The threshold shown here is the original v2.6 value. The current code uses 10,500s (moon) and 10,800s (sun), about half a cycle and half a day respectively, which indicates an epoch mismatch from a different game instance rather than ordinary drift (see MoonwatchOffsetManager constants and technical reference section 5.1). Since v4.2 the fractional cycle constants remove the systematic drift described below, so offsets no longer grow over time and the auto-reset is now only an epoch-mismatch guard.

If an offset grows beyond ±1800 seconds (30 minutes), it's automatically reset to 0:

if $moon_offsets[moon].abs > 1800
  $moon_offsets[moon] = 0
  CharSettings["#{moon}_offset"] = 0
end

Why offsets grow: If cycle constants aren't perfectly accurate, each cycle introduces a small drift that accumulates in the offset. For example, if the true cycle differs by 2 seconds from our constant, after 100 days the offset would grow to ~700 seconds.

The 1800s threshold catches runaway offsets before they cause major prediction errors while allowing legitimate drift accumulation.

Shared Moons Module

The Moons module is defined by moonwatch and can be used by other scripts:

module Moons
  CONSTANTS = { ... }  # Moon epoch, cycle, visible duration

  def self.calculate_position(moon, game_time, offset = 0)
    # Returns: { visible: bool, seconds: int, event: 'rise'|'set' }
  end

  def self.format_duration(seconds)
    # Returns: "1h 30m" or "45m 30s" or "30s"
  end
end

Exported Globals for External Scripts

moonwatch exports the following globals for use by external scripts:

Global Type Description
$moon_offsets Hash Per-moon offset corrections: {'katamba' => N, 'xibar' => N, 'yavash' => N}

The $moon_offsets hash is a direct reference to MoonwatchOffsetManager#moon_offsets, so it automatically reflects any runtime corrections made by moonwatch.

moonpredict.lic uses this module and requires moonwatch to be running:

unless defined?(Moons) && defined?($moon_offsets)
  respond "ERROR: moonwatch.lic must be running first."
  exit
end

# Use shared module and offsets
data = Moons.calculate_position('katamba', game_time, $moon_offsets['katamba'])

Firebase Write Deduplication

if $share_moon_data
  sleep(rand * 5)  # Random delay to spread writes

  existing = get_all_moon_data&.dig(moon[0])
  if existing.nil? || existing['e'] != (is_up ? RISE : SET) || (game_time - existing['t']).abs > 60
    update_moon_data(moon[0], 't' => game_time, 'e' => is_up ? RISE : SET)
  end
end

Firebase Data Guards (v2.5)

Date: December 2024 Status: Implemented

When syncing offsets from Firebase, two guards prevent bad corrections:

1. Staleness Check

Stale data (older than 1 hour) is ignored. This prevents bad corrections when:

  • Constants change: Old Firebase events were calibrated to previous cycle values. After updating constants, position calculations at old timestamps differ, causing spurious ~21000s corrections (full cycle length).
  • Data gaps: If no one has been online, Firebase may hold very old events that don't reflect current accuracy.

2. Local Observation Preference

Our own direct observations always take precedence over Firebase data. If we've observed a moon/sun event more recently than Firebase's timestamp, we skip the Firebase sync for that body.

# Track when we last directly observed each moon/sun event
$last_observed ||= {}

def moon_change(moon, is_up)
  game_time = XMLData.server_time
  $last_observed[moon] = game_time  # Record our observation
  # ... rest of correction logic
end

def sync_offsets_from_firebase
  %w[katamba xibar yavash].each do |moon|
    data = firebase_data[moon[0]]

    # Skip stale data (> 1 hour old)
    next if (current_time - data['t']).abs > 3600

    # Prefer our own observations over Firebase
    local_obs = $last_observed[moon]
    next if local_obs && local_obs >= data['t']

    # ... proceed with sync only if Firebase is newer
  end
end

Why this matters: Direct observations are more reliable than Firebase because:

  • No network latency or sync delays
  • No risk of stale data from other clients with different constants
  • Corrections are based on events we actually witnessed

Symptom of stale data corruption: CSV drift values near full cycle lengths (20866, 21090, 21145) indicate Firebase synced from old events after constants changed.


Data Structure

Firebase Format

All timestamps stored in Firebase use XMLData.server_time (game time), NOT Unix epoch time. This ensures consistency since all clients receive the same server_time from the game, regardless of local clock differences.

# Moon data (keyed by first letter: 'k', 'x', 'y')
{ 'k' => { 't' => 49623621, 'e' => 1 } }  # Katamba rose at server_time 49623621
# 't' = server_time when event occurred
# 'e' = event type (1 = RISE, 0 = SET)

# Sun data (keyed by 's')
{ 's' => { 'r' => 49623000, 's' => 49634000 } }
# 'r' = server_time of last sunrise
# 's' = server_time of last sunset

When reading sun data for display, convert to Unix time:

server_time_offset = XMLData.server_time_offset rescue 0
set_time = Time.at(sun_data['s'] + server_time_offset).localtime

UserVars.moons['katamba']

{
  # Backward compatible (minutes)
  'timer'          => 45,
  'pretty'         => "katamba is up for 45 minutes",
  'short'          => "[k]+(45)",

  # New: second precision
  'visible'        => true,        # Boolean: is moon currently up?
  'timer_seconds'  => 2705         # Seconds until next event
}

Usage Examples

# Existing code still works:
if UserVars.moons['katamba']['timer'] > 5
  # Moon has at least 5 minutes...
end

# New second-precision usage:
if UserVars.moons['katamba']['visible']
  seconds_left = UserVars.moons['katamba']['timer_seconds']
end

Command Line Arguments

Pre-v4.0. This section describes the Firebase-era argument set. The correct / nocorrect arguments and the entire Firebase sharing path were removed in v4.0 (see technical reference section 9 and the v4.0 changelog). The current arguments are debug/nodebug, window/nowindow, log/nolog, alias, and reset; there is no sharing argument.

All toggle arguments persist to CharSettings and remain active for future runs until explicitly disabled.

Argument Description
debug Enable debug output (persistent)
nodebug Disable debug output
alias Add a moon alias to display status
window Enable moon status window (persistent)
nowindow Disable moon status window
correct Enable sharing observed events to Firebase (persistent)
nocorrect Disable sharing moon events to Firebase
log Enable moon event logging for calibration (persistent, per-character)
nolog Disable moon event logging
reset Reset all moon offsets to zero and exit

Toggle System

The debug, correct, and window arguments work as persistent toggles:

# Enable/disable debug permanently when arg is passed
if args.debug
  CharSettings['moon_debug'] = true
  echo("moonwatch: Debug mode enabled (persistent).")
end
if args.nodebug
  CharSettings['moon_debug'] = false
  echo("moonwatch: Debug mode disabled.")
end
$debug_mode_mm = CharSettings['moon_debug'] || UserVars.moon_debug

Storage locations (all per-character):

  • CharSettings['moon_debug'] - Debug mode toggle
  • CharSettings['moon_correct'] - Firebase sharing toggle
  • CharSettings['moon_window'] - Moon window toggle
  • CharSettings['moon_logging'] - Event logging toggle
  • CharSettings['katamba_offset'] - Katamba offset correction
  • CharSettings['xibar_offset'] - Xibar offset correction
  • CharSettings['yavash_offset'] - Yavash offset correction
  • CharSettings['sun_offset'] - Sun offset correction

Settings

Can also be configured via (for backward compatibility):

  • UserVars.moon_debug = true - Enable debug mode
  • UserVars.moonwatch_share = true - Enable Firebase sharing
  • YAML: moonwatch_share: true

Multi-Instance Support

Pre-v4.0. This table reflects the Firebase era. Since v4.0 there is no Firebase and no correct argument, and the sun is computed locally, so every feature below (moons, window, self-correction, sun tracking) works identically on all instances. The "Running without Firebase" startup message no longer exists.

The script works on all DragonRealms instances (Prime, Platinum, Fallen, Test). Historic (Firebase-era) capability matrix:

Feature DR Prime Other Instances
Moon calculations
Moon window
Self-correction on events
Firebase sync (removed v4.0)
Sun tracking (now local, all instances)
correct argument (removed v4.0) Ignored

In the Firebase era, non-Prime instances showed a startup message that Firebase was unavailable. That message and the dependency are gone as of v4.0.


Debug Output

Toggle Confirmation Messages

When toggling settings, confirmation messages are shown:

moonwatch: Debug mode enabled (persistent).
moonwatch: Debug mode disabled.
moonwatch: Firebase sharing enabled (persistent).
moonwatch: Firebase sharing disabled.
moonwatch: Moon window enabled (persistent).
moonwatch: Moon window disabled.

Startup Messages (with debug enabled)

moonwatch: game=DR firebase_available=true
moonwatch: current server_time=1766525245
moonwatch: current server_time_offset=-31440445
moonwatch: stored offsets: katamba=0, xibar=0, yavash=0

Event Correction Messages

moonwatch: moon_change katamba:true at 1766525245
moonwatch: katamba offset corrected by +2705s (total: 2705s)
moonwatch: shared katamba rise to Firebase
moonwatch: xibar offset synced from Firebase: +1830s (total: 1830s)
moonwatch: skipped Firebase write - yavash already recorded

Reset Messages

moonwatch: Resetting all moon offsets to zero...
moonwatch: Offsets reset. Restart moonwatch to re-sync.
--- Lich: moonwatch has exited.

Removed Features

Feature Reason
check_for_new_moons() No longer needed - calculations work immediately
Settings['rise'] / Settings['set'] Replaced by MOON_CONSTANTS
Frequent Firebase polling Reduced to hourly sync

Files Modified

File Changes
moonwatch.lic Complete rewrite of moon tracking logic, Moons module
moonpredict.lic Uses shared Moons module, requires moonwatch running

Files NOT Modified

These scripts still work unchanged due to backward-compatible timer field:

  • dependency.lic
  • gate.lic
  • combat-trainer.lic
  • autocontingency.lic
  • mm.lic

Game Time Source

Uses XMLData.server_time - the game server timestamp from the <prompt time="..."> XML tag.

Important: XMLData.server_time is NOT Unix epoch time. It is a game-specific timestamp that all connected clients receive from the server. This makes it ideal for cross-client synchronization since all players see the same value regardless of local clock differences.

To convert between time systems:

  • server_time → Unix: server_time + XMLData.server_time_offset
  • Unix → server_time: unix_time - XMLData.server_time_offset

Falls back to Time.now.to_i if unavailable (though this loses cross-client consistency).

Timestamp Refresh on Events (v2.8)

Problem: Moon and sun events are "push" events from the server - they arrive as ambient text (e.g., "Katamba slowly rises") without an accompanying prompt. Since XMLData.server_time only updates when a prompt is received, it could be minutes stale if the player has been idle.

Solution (v2.8): When a moon/sun event is detected, immediately issue a lightweight command (time) to force a fresh prompt before capturing the timestamp.

Removed in v4.0: XML log analysis revealed that moon and sun events DO trigger prompts with fresh server_time — the v2.8 assumption was wrong. The time command received the same timestamp as the event's own prompt (zero jitter), making it redundant. Event handlers now use XMLData.server_time directly. See v4.0 changelog for full analysis.


Verification Checklist

Moons

  • UserVars.moons['katamba']['timer'] returns integer minutes
  • UserVars.moons['katamba']['timer_seconds'] returns seconds
  • UserVars.moons['katamba']['visible'] returns boolean
  • Moon predictions accurate on fresh start (no Firebase)
  • Self-correction when moon events observed

Sun & Calendar

  • UserVars.sun['timer'] returns integer minutes
  • UserVars.sun['timer_seconds'] returns seconds
  • UserVars.sun['day'] / UserVars.sun['night'] return booleans
  • UserVars.sun['season'] returns season string
  • UserVars.sun['time_of_day'] returns time period string
  • UserVars.calendar['date_string'] returns formatted date
  • Sun predictions accurate on fresh start (no Firebase)
  • Self-correction when sun events observed
  • Variable day length works (shorter in winter, longer in summer)

General

  • Firebase reads reduced to hourly
  • Firebase writes only with correct enabled
  • Write deduplication prevents storms
  • debug/nodebug toggle persists to CharSettings
  • correct/nocorrect toggle persists to CharSettings
  • window/nowindow toggle persists to CharSettings
  • Toggle confirmation messages display correctly
  • reset command clears both moon AND sun offsets

Troubleshooting

Moon calculations are wrong

If moon visibility doesn't match what you see in-game:

  1. Reset offsets: ;moonwatch reset (resets and exits)
  2. Restart: ;moonwatch or ;moonwatch debug correct
  3. Wait for sync: On startup, offsets sync from Firebase (if available)
  4. Observe events: Offsets auto-correct when you see moon rise/set messages

Moon window shows zeros or doesn't update

The moon window cache is cleared on script startup. If showing stale data:

  1. Kill and restart: ;kill moonwatch then ;moonwatch window
  2. The cache is now always cleared on startup to prevent stale display

Stream window XML format: The window uses clean XML without \r\n for Profanity compatibility:

_respond("<clearStream id=\"moonWindow\"/>")
_respond("<pushStream id=\"moonWindow\"/>#{new_message}<popStream/>")

Note: The window only updates when the minute value changes (timer is in minutes). A 60-second periodic refresh ensures the window re-emits XML even when no game text is flowing (e.g., after a disconnect/reconnect or during idle periods).

Alias shows stale values

The moon alias created by ;moonwatch alias must use escaped interpolation so values are evaluated at invocation time, not creation time:

# Wrong - evaluates immediately when alias is created
%{eq respond("#{UserVars.moons['katamba']['pretty']} ...")}

# Correct - escapes # so interpolation happens when alias is invoked
%{eq respond("\#{UserVars.moons['katamba']['pretty']} ...")}

If your alias shows static values, run ;moonwatch alias again to recreate it.

Offset correction logic

There are two types of corrections:

1. Visibility mismatch (major correction): When the model is completely wrong about visibility, corrections are positive:

  • If we predicted hidden but moon rose: add predicted[:seconds] to shift position to 0
  • If we predicted visible but moon set: add predicted[:seconds] to shift position to visible_duration

2. Drift correction (minor correction): When visibility matched but timing was slightly off:

  • Calculate current position in cycle when event occurs
  • For rise events: drift = position (should be 0 at rise)
  • For set events: drift = position - visible_duration (should be 0 at set)
  • Apply offset -= drift which works for both directions:
    • Positive drift (predicted early): offset decreases, shifting prediction later
    • Negative drift (predicted late): offset increases, shifting prediction earlier

All observed drift is corrected immediately (v2.10). In the pure-observation model, every event we process is freshly witnessed, so there's no "stale event" concern that would justify ignoring large drifts.


Sun Tracking (v2.1)

Date: December 2024 Status: Implemented

Sun tracking has been added using the same epoch-based approach as moons, ported from Genie4's TimeTracker plugin.

DRTime Module

A new DRTime module provides full DragonRealms calendar calculations:

module DRTime
  SECONDS_PER_DAY = 21600      # 6 real hours = 1 DR day
  DAYS_PER_YEAR = 400          # 10 months × 40 days

  # Calibrated 2024-12-26 from in-game TIME command
  # At server_time 1766732083: Year 455, Month 1, Day 17, Anlas 10 (Phelim's Vigil)
  CALENDAR_EPOCH = 1_688_607_583
  CALIBRATION_YEAR = 446

  # Calculate sun rise/set for a day (0-399)
  # Uses quantized sinusoidal formula derived from observed data (2026-03)
  # See "Sun Formula Derivation (v2.13)" section below for full details
  def self.sun_times_for_day(day_of_year)
    theta = 2 * Math::PI * (day_of_year + SUN_PHASE_SHIFT) / DAYS_PER_YEAR.to_f
    smooth_day_length = SUN_DAY_LENGTH_BASE - SUN_DAY_LENGTH_AMPLITUDE * Math.sin(theta)
    day_length = (smooth_day_length / SUN_QUANTUM).floor * SUN_QUANTUM
    night_length = SECONDS_PER_DAY - day_length
    rise_seconds = night_length / 2
    set_seconds = rise_seconds + day_length
    { rise: rise_seconds, set: set_seconds }
  end

  def self.calculate_date(game_time, offset = 0)
    # Returns: year, month, day, day_of_year, anlas, rois, seconds_in_day
  end

  def self.calculate_sun_position(game_time, offset = 0)
    # Returns: { visible: bool, seconds: int, event: 'rise'|'set' }
  end

  # Returns season directly (no lookup array needed)
  def self.season(day_of_year)
    case day_of_year
    when 0...50   then 'winter'
    when 50...150 then 'spring'
    when 150...250 then 'summer'
    when 250...350 then 'fall'
    else 'winter'  # days 350-399
    end
  end

  # Returns time period directly (no lookup array needed)
  def self.time_of_day(seconds_in_day, day_of_year)
    # Returns: 'night', 'dawn', 'midday', 'dusk', etc. (15 periods)
    # Uses if/elsif chain based on sun position
  end
end

Sun Self-Correction

When sunrise/sunset is observed in-game, the script verifies its prediction. Like moons, it handles both visibility mismatch and drift detection:

def sun_change(is_up)
  predicted = calculate_sun_position(game_time)
  date = DRTime.calculate_date(game_time, $sun_offset)
  sun_times = DRTime.sun_times_for_day(date[:day_of_year])

  if predicted[:visible] != is_up
    # Major correction: visibility was wrong
    discrepancy = predicted[:seconds]
    $sun_offset += discrepancy
    CharSettings['sun_offset'] = $sun_offset
  else
    # Drift detection: visibility matched but timing was slightly off
    drift = is_up ? (date[:seconds_in_day] - sun_times[:rise])
                  : (date[:seconds_in_day] - sun_times[:set])
    # Correct any drift - observations are ground truth
    if drift != 0
      $sun_offset -= drift
      CharSettings['sun_offset'] = $sun_offset
    end

    # Auto-reset if offset grows too large
    if $sun_offset.abs > 1800
      $sun_offset = 0
      CharSettings['sun_offset'] = 0
    end
  end
end

UserVars.sun Structure

{
  # Backward compatible
  'day'            => true,           # Is it daytime?
  'night'          => false,          # Is it nighttime?
  'timer'          => 45,             # Minutes until next event

  # New fields
  'visible'        => true,           # Alias for 'day'
  'timer_seconds'  => 2705,           # Seconds until next event
  'season'         => 'summer',       # Current season
  'time_of_day'    => 'mid-afternoon', # Descriptive time period
  'pretty'         => 'sun sets in 45 minutes (mid-afternoon)',
  'short'          => '[S]+(45)'
}

UserVars.calendar Structure

{
  'year'           => 455,
  'month'          => 1,
  'day'            => 17,
  'day_of_year'    => 16,             # 0-399
  'anlas'          => 10,             # 0-11 (hours)
  'rois'           => 18,             # 0-29 (minutes)
  'season'         => 'winter',
  'time_of_day'    => 'evening',
  'year_name'      => 'Year of the Silver Unicorn',
  'month_name'     => 'Akroeg the Ram',
  'anlas_name'     => "Phelim's Vigil",
  'date_string'    => '455-01-17 10:18'
}

Season Boundaries

Day Range Season
0-49 Winter
50-149 Spring
150-249 Summer
250-349 Fall
350-399 Winter

Time of Day Periods

15 descriptive periods based on sun position:

  1. night
  2. approaching sunrise
  3. dawn
  4. early morning
  5. mid-morning
  6. late morning
  7. midday
  8. early afternoon
  9. mid-afternoon
  10. late afternoon
  11. dusk
  12. sunset
  13. early evening
  14. evening
  15. late evening

Sun Formula Derivation (v3.1 - March 2026)

The sun timing formula was reverse-engineered through three iterations, culminating in the discovery that the game quantizes sunrise and sunset independently to rois boundaries.

Elanthipedia Reference Formula

From Elanthipedia Talk:Elanthian_time:

f(day) = 30min × sin(2πday/400 + X×π/2) + 75min

Where X=1 for sunrise, X=3 for sunset. This simplifies to:

  • Sunrise shift: sin(π(d+100)/200) = cos(2πd/400)
  • Sunset shift: sin(π(d+300)/200) = -cos(2πd/400)

Applied with base and amplitude:

  • Sunrise(d) = 5400 + 1800 × cos(2πd/400) seconds
  • Sunset(d) = 16200 - 1800 × cos(2πd/400) seconds

Key Discovery: Rois-Level Quantization

Analysis of 82 observed sun events (with corrected CALENDAR_EPOCH) revealed:

Finding Value
Rise/set times are multiples of 60 seconds (1 rois) -- not 120s
rise + set always equals 21600s (perfectly symmetric around midday)
Rise step between consecutive days Always exactly 0 or 60 seconds
Each rois level lasts 2-4 days (sinusoidal dwell pattern)
Day-length steps of 120s Consequence of independent 60s rise + 60s set steps

Derived Formula (v3.5)

rise_rois = round(90 + 29.51 × cos(2πd/400))
set_rois  = 360 - rise_rois

Constants (defined in DRTime module):

Constant Value Purpose
SUN_RISE_BASE_ROIS 90 Equinox sunrise (rois after midnight)
SUN_RISE_AMPLITUDE_ROIS 29.51 Seasonal swing (confirmed from solstice data)
ROIS_PER_DAY 360 Total rois in one DR day

Amplitude derivation (v3.5): Winter solstice data (days 385-399, day 0) resolved the amplitude definitively. Day 398 shows day_length=7320 (rise_rois=119), day 399 shows day_length=7200 (rise_rois=120). Both characters confirm identical values.

The transition from rise_rois=119 to 120 at exactly day 399 constrains B tightly:

  • round(90 + B × cos(2π×399/400)) >= 119.5 requires B >= 29.504
  • round(90 + B × cos(2π×398/400)) < 119.5 requires B < 29.515
  • Therefore B in [29.504, 29.515), and 29.51 is the natural choice

This rules out both A=29 (predicted day_length=7320 at solstice, observed 7200) and A=30 (predicted day_length=7200 at day 389, observed 7320).

Day Length Range

Season Day Rise Rois Day Length Real Time
Winter solstice 0 120 7200s 2.0h
Spring equinox 100 90 10800s 3.0h
Summer solstice 200 60 14400s 4.0h
Fall equinox 300 90 10800s 3.0h

Summer solstice values are predicted (no observations in days 1-239 yet). Winter solstice values are confirmed from days 399 and 0.

Implementation

def self.sun_times_for_day(day_of_year)
  theta = 2 * Math::PI * day_of_year / DAYS_PER_YEAR.to_f
  smooth_rise_rois = SUN_RISE_BASE_ROIS + SUN_RISE_AMPLITUDE_ROIS * Math.cos(theta)
  rise_rois = smooth_rise_rois.round
  set_rois = ROIS_PER_DAY - rise_rois

  { rise: rise_rois * SECONDS_PER_ROIS, set: set_rois * SECONDS_PER_ROIS }
end

Formula Evolution

Version Model Accuracy
v2.12 day_length = BASE - AMP × sin(2π(d+PHASE)/400) (smooth) ~60%
v2.13 day_length = floor(smooth / 120) × 120 (120s buckets) ~73%
v3.1 rise_rois = round(90 + 29 × cos(2πd/400)) (rois-level) ~82%
v3.5 rise_rois = round(90 + 29.51 × cos(2πd/400)) (solstice-confirmed) ~79%

The v3.5 accuracy is slightly lower than v3.1's reported 82% because the dataset now includes 150 observations (days 240-399, day 0) vs 82 (days 240-320). The new data covers the winter solstice region where the model performs well, but the larger dataset includes more equinox-transition days where accuracy is inherently lower.

Accuracy by day range (150 observations, 2 characters):

Day Range Season Accuracy
240-260 Late summer 60% (12/20)
261-300 Fall equinox 51% (19/37)
301-340 Mid-fall 95% (36/38)
341-399 Winter 94% (51/54)
0 Solstice 100% (1/1)

Model Ceiling Analysis (v3.5)

The ~79% accuracy is a hard mathematical limit of the cosine model, not a tuning problem. No values of (base, amplitude, phase) can satisfy all observations:

  • Day 376 requires B >= 29.577
  • Day 241 requires B < 29.387
  • Day 298 requires B < 15.918

These constraints are mutually exclusive. The game's actual sun function is close to a cosine but not identical -- likely integer arithmetic, a lookup table, or a slightly different curve. The error pattern is systematic:

  • Days 241-298 (fall equinox): model predicts rise_rois 1 too low (all +1 misses)
  • Days 310-376 (winter approach): scattered -1 misses
  • Days 322-399 (deep winter): ~95% accuracy (flat cosine region)

All misses are exactly +/-1 rise_rois (60 seconds). The offset correction system corrects each error immediately upon observing the next sunrise/sunset event.

Moon comparison: Moon constants achieve 100% interval prediction accuracy (all observed intervals land on exactly one of two quantized values). The difference is that moon cycles are constant, while the sun formula involves day-dependent calculations where the game's exact quantization doesn't match pure cosine rounding.

Debug Output

With debug enabled:

moonwatch: sun visible=false timer=160m (evening)
moonwatch: calendar: 455-01-17 10:18 Phelim's Vigil
moonwatch: season=winter Year of the Silver Unicorn

Epoch Calibration

The CALENDAR_EPOCH has been calibrated through three methods:

Method 1 -- TIME command (2024-12, approximate): Derived epoch 1,688,607,583 from a single "Rois ~15" reading. Imprecise.

Method 2 -- Sun event recalibration (2025-02, counterproductive): Shifted -380s to 1,688,607,203 based on early sun drift analysis. This actually increased calendar error from 7 to 14 rois.

Method 3 -- Sun rois alignment + TIME verification (2026-03, definitive): 82 sun events all land on exact rois (60s) boundaries with epoch 1,688,607,948. Two "positive" in-game TIME readings confirm the calendar is accurate to +-1 rois.

# Verification at server_time 1773308339:
# Game: "You're positive it's 7 roisaen before Hodierna's Blessing" = anlas 3, rois 23
# Epoch gives: anlas 3, rois 23.18 -> floor 23 ✓
CALENDAR_EPOCH = 1_688_607_948

Moon Event Logging (v2.6)

Date: January 2025 Status: Reimplemented with raw intervals

Moon event logging now captures raw interval measurements instead of model-relative drift. This provides clean data for constant recalibration without offset contamination.

Enabling Logging

;moonwatch log      # Enable for this character
;moonwatch nolog    # Disable

Logging is per-character and persists across sessions.

Log File

Location: $lich_dir/data/moonwatch_events_<CharName>.csv

Columns:

Column Description
server_time XMLData.server_time when event observed
moon katamba, xibar, or yavash
event rise or set
day_of_year 0-399
season winter, spring, summer, fall
last_rise server_time of previous rise for this moon
last_set server_time of previous set for this moon
cycle_interval Rise->rise or set->set interval (full cycle measurement)
visible_dur Rise->set interval (how long moon was up)
hidden_dur Set->rise interval (how long moon was down)

Why Raw Intervals?

The previous logging system recorded drift (difference between prediction and observation). The problem: drift values were contaminated by accumulated offset corrections, making them unreliable for constant recalibration.

Raw intervals are absolute measurements that directly tell us:

  • cycle_interval = actual cycle length (compare to our constant)
  • visible_dur = actual visible duration
  • hidden_dur = actual hidden duration

Analysis After One Elanthian Year

After ~100 real days (one Elanthian year of 400 DR days):

require 'csv'
data = CSV.read('moonwatch_events_Quilsilgas.csv', headers: true)

%w[katamba xibar yavash].each do |moon|
  cycles = data.select { |r| r['moon'] == moon && r['cycle_interval'].to_s != '' }

  # Overall average cycle length
  avg = cycles.map { |r| r['cycle_interval'].to_i }.sum.to_f / cycles.size
  puts "#{moon}: avg_cycle=#{avg.round(1)}s (n=#{cycles.size})"

  # By season (check for seasonal variation)
  cycles.group_by { |r| r['season'] }.each do |season, rows|
    s_avg = rows.map { |r| r['cycle_interval'].to_i }.sum.to_f / rows.size
    puts "  #{season}: #{s_avg.round(1)}s (n=#{rows.size})"
  end
end

Sun Event Logging (v2.7)

Date: January 2025 Status: Implemented

Sun event logging captures raw interval measurements to validate the CALENDAR_EPOCH and sinusoidal day length formula.

Log File

Location: $lich_dir/data/sunwatch_events_<CharName>.csv

Columns:

Column Description
server_time XMLData.server_time when event observed
event rise or set
day_of_year 0-399
season winter, spring, summer, fall
last_rise server_time of previous sunrise
last_set server_time of previous sunset
day_interval Rise->rise or set->set interval (should be ~21600s)
day_length Rise->set interval (daylight duration, varies by season)
night_length Set->rise interval (night duration, varies by season)
expected_day_length Model's predicted day length for this day_of_year
drift Observed day_length - expected_day_length

What This Validates

  1. CALENDAR_EPOCH accuracy: If day_interval consistently differs from 21600s, the epoch may need adjustment
  2. Sinusoidal formula: If drift values show systematic bias by season, the day length formula needs tuning
  3. SECONDS_PER_DAY: If day_interval drifts over time, the constant may be wrong

Analysis Example

require 'csv'
data = CSV.read('sunwatch_events_Quilsilgas.csv', headers: true)

# Check day interval consistency (should be ~21600)
intervals = data.map { |r| r['day_interval'].to_i }.reject(&:zero?)
avg_interval = intervals.sum.to_f / intervals.size
puts "avg day_interval: #{avg_interval.round(1)}s (expected: 21600s)"

# Check drift by season
data.select { |r| r['drift'].to_s != '' }.group_by { |r| r['season'] }.each do |season, rows|
  avg_drift = rows.map { |r| r['drift'].to_i }.sum.to_f / rows.size
  puts "#{season}: avg_drift=#{avg_drift.round(1)}s (n=#{rows.size})"
end

Constant Calibration (v2.4)

Date: December 2024 Status: Implemented

Analyzed moondata.2024 (1200+ events per moon over a full year) to validate and refine moon constants.

Key Finding: Moon Cycles are CONSTANT

Unlike sun day length, moon cycles show no seasonal variation. Cycle times vary by only ~1-2s across all seasons - well within measurement noise.

Analysis Results

Moon Duration Old Value Observed New Value Change
Katamba visible 10605s 10603s 10605s -
Katamba hidden 10485s 10486s 10485s -
Katamba cycle 21090s 21089s 21090s -
Xibar visible 10485s 10482s 10485s -
Xibar hidden 10365s 10384s 10381s +16s
Xibar cycle 20850s 20866s 20866s +16s
Yavash visible 10621s 10623s 10621s -
Yavash hidden 10499s 10524s 10524s +25s
Yavash cycle 21120s 21147s 21145s +25s

Updated Constants

# Calibrated 2025-01-03 from moonwatch_events CSV analysis
CONSTANTS = {
  'katamba' => { cycle: 21_084, visible: 10_608 },  # Was 21090/10605
  'xibar'   => { cycle: 20_852, visible: 10_482 },  # Was 20866/10485
  'yavash'  => { cycle: 21_133, visible: 10_632 }   # Was 21145/10621
}

Seasonal Consistency

Day-of-year analysis (50-day buckets) shows consistent cycles:

Katamba: cycle varies by ~1s across all seasons - CONSTANT
Xibar:   cycle varies by ~2s across all seasons - CONSTANT
Yavash:  cycle varies by ~1s across all seasons - CONSTANT

Analysis Script

The analysis was performed with scripts/custom/analyze_moondata.rb:

  • Parses ISO timestamps from moondata.2024
  • Calculates DR day_of_year for each event
  • Computes intervals between consecutive rise/set events
  • Groups by season and day-of-year buckets
  • Compares observed intervals to expected constants

Constant Calibration (v2.7)

Date: January 2025 Status: Implemented

Re-analyzed constants using 443 events from moonwatch_events_Quilsilgas.csv (days 53-135, spring season).

Analysis Results

Moon Metric v2.4 Value Observed v2.7 Value Change
Katamba cycle 21,084s 21,089.7s 21,090s +6s
Katamba visible 10,608s 10,601.4s 10,601s -7s
Katamba hidden 10,476s 10,485.7s 10,489s +13s
Xibar cycle 20,852s 20,849.4s 20,852s -
Xibar visible 10,482s 10,481.0s 10,482s -
Xibar hidden 10,370s 10,367.1s 10,370s -
Yavash cycle 21,133s 21,129.3s 21,129s -4s
Yavash visible 10,632s 10,622.9s 10,623s -9s
Yavash hidden 10,501s 10,505.5s 10,506s +5s

Updated Constants

# Calibrated 2025-01-25 from moonwatch_events CSV analysis (n=443)
CONSTANTS = {
  'katamba' => { cycle: 21_090, visible: 10_601 },  # Was 21084/10608
  'xibar'   => { cycle: 20_852, visible: 10_482 },  # No change
  'yavash'  => { cycle: 21_129, visible: 10_623 }   # Was 21133/10632
}

Measurement Quality

Standard deviations (~20-30s) reflect timing noise from XMLData.server_time capture. This is expected and handled by the self-correction mechanism.

Moon Cycle StdDev Visible StdDev Hidden StdDev n (cycles)
Katamba 29.9s 27.6s 26.0s 107
Xibar 30.1s 27.9s 24.5s 100
Yavash 21.7s 12.8s 17.2s 110

Server Tick Quantization Discovery (v2.9)

Date: February 2026 Status: Implemented

Key Finding: 60-Second Server Tick Cycle

Analysis of CSV data with second-level timestamp precision revealed that all observed moon intervals are exact multiples of 60 seconds:

Xibar cycles:   20820, 20880, 20820, 20880... (alternating)
Katamba cycles: 21120, 21060, 21120, 21060... (alternating)
Yavash cycles:  21120, 21180, 21120, 21180... (alternating)

Verification:

  • 20820 / 60 = 347 ✓
  • 20880 / 60 = 348 ✓
  • 21060 / 60 = 351 ✓
  • 21120 / 60 = 352 ✓
  • 21180 / 60 = 353 ✓

Root Cause

The game server checks moon positions on a 60-second tick cycle. Moon rise/set events only fire on these tick boundaries. This quantization is inherent to the game engine, not our measurement system.

Implications

  1. Timestamp precision is NOT the bottleneck - We have second-level accuracy (v2.8), but the game only fires events every 60s
  2. Constants can only be verified to +-30s - The "true" cycle lies somewhere in a 60s band
  3. Bimodal distributions are expected - Alternating values (e.g., 20820/20880) are normal, not measurement error
  4. Use midpoint values - Set constants to the midpoint of the observed 60s band for best average accuracy

Constant Recalibration (v2.9)

Based on 68 events (days 240-251, summer/fall transition):

Moon Metric v2.7 Value Observed Band v2.9 Value (midpoint)
Katamba cycle 21,090 21060/21120 21,090 (no change)
Katamba visible 10,601 10620 only 10,620
Xibar cycle 20,852 20820/20880 20,850
Xibar visible 10,482 10440/10500 10,470
Yavash cycle 21,129 21120/21180 21,150
Yavash visible 10,623 10620/10680 10,650

Constant Recalibration (v2.12)

Based on 334 events from 2 characters (days 240-271, summer/fall):

Key insight: Midpoint works for 50/50 distributions, but for skewed distributions use weighted average to minimize drift.

Moon Metric v2.11 Value Distribution v2.12 Value
Katamba cycle 21,090 21060 (50%) / 21120 (50%) 21,090 (no change)
Katamba visible 10,590 10560 (22%) / 10620 (78%) 10,590 (no change)
Xibar cycle 20,850 20820 (56%) / 20880 (44%) 20,850 (no change)
Xibar visible 10,470 10440 (27%) / 10500 (73%) 10,484 (+14s)
Yavash cycle 21,120 21120 (85%) / 21180 (15%) 21,129 (+9s)
Yavash visible 10,620 10620 (91%) / 10680 (9%) 10,625 (+5s)

Methodology:

  • 50/50 splits -> midpoint (e.g., Katamba cycle: (21060+21120)/2 = 21090)
  • Skewed splits -> weighted average (e.g., Xibar visible: 0.27×10440 + 0.73×10500 = 10484)

Why Offsets Were Drifting

With the old constants (e.g., xibar cycle=20852), the model predicted to the second, but actual events only fired on 60s boundaries. This caused:

  1. Systematic +-30s "error" each cycle as events snapped to tick boundaries
  2. Offset chasing - the self-correction logic constantly adjusted offsets
  3. Drift accumulation - if constants were off-center, offsets grew in one direction

By centering constants at the midpoint of observed bands, the +-30s jitter becomes symmetric around zero rather than accumulating.


Future Considerations

  1. Sun tracking - Could apply same epoch-based approach to sun rise/set ✓ Implemented
  2. Offset sharing - Could sync offsets via Firebase instead of raw events
  3. Constant validation - Debug mode reports drift to help refine constants over time ✓ Logging reimplemented with raw intervals
  4. Time conversion commands - Add /time command for date conversion (like TimeTracker)
  5. Seasonal moon analysis - Use collected CSV data to determine if moon periods vary by season ✓ Analyzed - cycles are constant
  6. Per-character settings - Store offsets per-character instead of per-game ✓ Implemented (v2.6)
  7. Auto-reset runaway offsets - Reset offsets that grow too large ✓ Implemented (v2.6, threshold 1800s; later raised to 10,500s moon / 10,800s sun as an epoch-mismatch guard)
  8. Self-updating moon constants (v3) - Eliminate manual recalibration entirely Superseded by v4.0 — split ratio analysis proved constants are the game's exact integer values; no recalibration needed
  9. Server reset tracking - Document and analyze moon phase shifts after monthly resets
  10. Sun amplitude confirmation - Observe winter/summer solstice to finalize amplitude ✓ Confirmed: B=29.51 (v3.5)
  11. Spring/summer sun data - Collect days 1-239 to complete the full-year truth table and potentially reverse-engineer the game's exact sun algorithm
  12. Sun lookup table - Build a 400-day day_of_year to rise_rois mapping from observed events, replacing the cosine formula for observed days ✓ Implemented (v4.1.0). 400-entry rise and set tables embedded in DRTime, cosine kept as a fallback; set stored independently rather than derived as 360 - rise.

Server Reset Tracking (v2.14 - planned)

Date: March 2026 Status: Investigation phase

Observed: March 4, 2026 Reset

Analysis of moon data around the monthly server reset revealed phase shifts:

Moon Pre-Reset Offset Post-Reset Offset Phase Shift
Katamba 60s 60s 0s (no change)
Xibar 90s 163s +73s (later)
Yavash -24s -35s -11s (earlier)

Key finding: Xibar showed a significant +73s phase shift after the server reset, beyond normal +-30s tick noise. This confirms speculation that server resets can affect moon phases.

Questions to Answer

  1. Are phase shifts consistent across resets? (always same direction/magnitude)
  2. Are they random? (different each reset)
  3. Are they correlated? (all moons shift together, or independent)
  4. Does shutdown duration matter?
  5. Is server_time continuous across resets?

Sample Size Needed

Resets Confidence Timeline
1 Anecdotal Current
3 Preliminary pattern 3 months
5 Moderate confidence 5 months
10+ High confidence 10+ months

Implemented Scaffolding (v2.14)

1. Shutdown detection (SHUTDOWN_PATTERN):

  • Pattern: /DragonRealms will be shutting down/i
  • Triggered in main event loop
  • Calls reset_tracker.log_shutdown(game_time, current_offsets)

2. Gap detection (ServerResetTracker.check_for_restart):

  • Threshold: GAP_THRESHOLD_SECONDS = 21480 (max cycle + 5min buffer)
  • Called in both MOON_SET_PATTERN and MOON_RISE_PATTERN handlers
  • Returns true if gap exceeds threshold, triggering phase analysis

3. Reset log file:

Location: $lich_dir/data/server_resets_<CharName>.csv

Column Description
event_type 'shutdown', 'restart_detected', or 'phase_shift'
server_time XMLData.server_time
timestamp Human-readable timestamp
katamba_offset Offset at event time
xibar_offset Offset at event time
yavash_offset Offset at event time
katamba_shift Phase shift (phase_shift events only)
xibar_shift Phase shift (phase_shift events only)
yavash_shift Phase shift (phase_shift events only)
gap_seconds Time since last event (restart_detected only)
trigger_moon Moon that triggered detection (restart_detected only)

4. Post-restart analysis (ServerResetTracker.analyze_phase_shifts):

  • Compares pre-shutdown offsets to current offsets
  • Logs phase_shift event to CSV
  • Alerts user for significant shifts (>60s)

Implementation Plan

  1. Add shutdown detection hook ✓ v2.14
  2. Add gap detection in moon_change ✓ v2.14
  3. Create server_resets CSV logging ✓ v2.14
  4. Add pre/post offset comparison ✓ v2.14
  5. Document each reset as it occurs (ongoing)
  6. After 3 resets, analyze for patterns (pending data)

Changelog

v4.5.0 (July 2026): Instance-awareness, self-aware alias, de-fork

  • MoonwatchInstance. Explicit game-instance awareness. The moon and sun periods are game-code constants (cross-validated against the DR client's hard-coded sidereal periods to ~0.05s), identical on every instance; offsets are already scoped per "#{XMLData.game}:#{XMLData.name}" in CharSettings, so each instance/character self-calibrates independently. Non-Prime instances (Platinum/ Fallen/Test) print a one-time startup notice. This supersedes the old Prime-only refuse-and-exit guard.
  • Self-aware, self-updating moon alias (MoonwatchAlias). The alias body is gated on Script.running?('moonwatch'), so it reports "moonwatch is not running" instead of echoing stale data (crash-proof; does not depend on exit cleanup). On startup, resync reads the alias service DB (data/alias.db3, table global) and silently upgrades an existing moonwatch moon alias to the current body if it differs. It never creates an alias the user did not ask for and never overwrites an unrelated moon alias.
  • UserVars.*['running'] freshness flag. MoonwatchUI.set_running marks moon/sun/calendar data live at startup and stale on a clean exit (via before_dying), non-destructively.
  • Retired the crowdsource layer for good (the offline engine had already replaced it in v4.0) and folded in the v4.4.2 observe work below, making the upstream script a superset of the prior personal fork so the fork could be retired.

v4.4.2 (July 2026): Broadened observe-phase capture

The v4.4.0 observe parser matched only ^The <colour> moon <M> is <phase>., so it captured just the waxing-crescent form and silently dropped the other seven (e.g. full is "forms a perfect circle in the heavens"). Capture is now scoped to the observe response (via the "You scan the skies" line) and matches any phase clause. Wordings are mapped to phase names by Moons::OBSERVED_PHASE_MAP; four are mapped and model-validated so far (waxing crescent, waxing gibbous, full, waning crescent), and unmapped wordings are logged raw so the remaining four phases can be added as they are seen. log_phase_observation reports mapped-vs-model MATCH/MISMATCH/unmapped in debug.

v4.4.1 (June 2026): Next-phase countdown

Adds each moon's current phase and an approximate next-phase countdown to the startup debug summary, and exposes next_phase / next_phase_seconds via UserVars. Phase buckets last roughly a real day.

v4.4.0 (June 2026): Auto-observe at rise

While ;moonwatch log is on, the script sends observe <moon> on each rise broadcast and logs the game's phase wording against the computed phase, building the phrase-to-phase map automatically. Rise broadcasts only reach a client that is outdoors, so it self-gates to observable moments; the 60s dedup prevents a manual observe and the auto-observe from double-logging.

v4.3.0 (June 2026): Lunar phase

Adds each moon's lunar phase (new / waxing crescent / first quarter / waxing gibbous / full / waning gibbous / third quarter / waning crescent) and a sky-position t (0..1 across the current arc). Phase is computed from the DR client's own sidereal orbital periods and calendar skew (kept self-contained in Moons.phase, not fed from the calibrated CALENDAR_EPOCH). It is model-only (no passive phase broadcast to correct against) but validated against the in-game observe <moon> verb.

v4.2.1 (June 2026): Restart Tracking Actually Works

Server-reset phase-shift logging was inert; two bugs fixed

The server_resets CSV only ever held shutdown rows, never restart_detected or phase_shift, even with moonwatch on autostart. Two compounding bugs:

  1. @last_moon_events was in-memory only. A server reset relaunches the script via autostart, so the fresh process started with all last-event times nil. The first post-restart event hit return false unless last_event and the gap was never seen. Gap detection could only fire within one continuous run, which a server reset prevents. Fix: persist last_moon_events to CharSettings (load on init, save on each event), mirroring how pre_shutdown_offsets already survived restarts.

  2. Phase shift was measured before the correction that reveals it. analyze_phase_shifts ran before correct_moon_offset using pre-correction offsets, which after a restart still equal the persisted pre-shutdown values, so every shift computed as zero. Fix: replaced it with a per-moon record_phase_shift, called AFTER the correction, logging (corrected offset minus pre-shutdown offset) for that moon. Each moon logs once on its first post-restart event; the snapshot clears after all three re-observe.

Also removed the dead update_event_time method (flagged in the earlier audit). Added ServerResetTracker spec coverage (persistence across a simulated restart, per-moon shift math, snapshot clearing). 123 examples, 0 failures.

No user action required; this only affects diagnostic logging.

v4.2.0 (June 2026): Fractional Moon Periods (drift-free)

Moon cycle constants switched from rounded integers to OLS fractional periods

The integer cycle constants differed from the game's true period by up to half a second (e.g. katamba 21089 vs true 21088.611, +0.39s/cycle), which accumulated as phase drift and required periodic epoch re-centering (v3.3 through v4.0.1). The cycle constants are now the OLS slope (katamba 21088.611, xibar 20848.143, yavash 21129.564) and the epochs are the OLS intercepts, fit from ~1,900 rise events over ~1.2 DR years across 2 characters.

  • No more drift. Replaying ~850 real events per moon from a zero offset keeps the offset within about 20s with no upward trend (was reaching ~85s in 6 weeks). Periodic re-centering is no longer needed.
  • Implementation. cycle is now a Float. calculate_position and correct_moon_offset floor the cycle-count division, nearest_tick floors so it accepts fractional input, and the drift correction is rounded so persisted offsets stay integer second counts.
  • Auto-reset threshold (this also records the previously-undocumented change the audit flagged): the moon/sun offset auto-reset is 10,500s / 10,800s (about half a cycle / half a day), an epoch-mismatch guard, not the original v2.6 1800s value.

Action required: run ;moonwatch reset once per character after updating; the moon epochs were re-anchored, so stored offsets from before v4.2 are stale.

v4.1.0 (June 2026): Empirical Sun Lookup Table

Sun cosine replaced by a day-of-year lookup table

The seasonal cosine (rise_rois = round(90 + 29.51 * cos(2*pi*d/400))) hit its mathematical ceiling at about 69% exact-rois match (mean error ~26s). The game's sun function is close to but not a true cosine, so no (base, amplitude, phase) fit does better. The cosine is now only a fallback. sun_times_for_day reads two 400-entry empirical tables (SUN_RISE_ROIS_TABLE, SUN_SET_ROIS_TABLE) keyed by day_of_year, built from about 1,900 observed sun events across 2 characters (~1.2 DR years).

  • About 100% exact-rois cold-start accuracy (was ~69%); the roughly 30% of days the cosine missed by 1 or 2 rois are now exact.
  • Rise and set stored independently. rise_rois + set_rois is 360 on about 84% of days but 358 or 361 on the rest, so set is no longer derived as 360 - rise.
  • Gap days interpolated. Rise gaps (27, 123, 124, 167, 366) and the set gaps have no observations; they are linearly interpolated from neighbors.
  • Storage: the tables are embedded constants in the DRTime module (so they ship and version with the script). A range guard sends out-of-range or negative days to the cosine fallback instead of indexing from the array end.
  • Regenerate from sunwatch_events_*.csv as more data accumulates.

Implements the path documented in the technical reference section 4.4, and resolves Future Considerations item 12.

v4.0.1 (May 2026) -- Epoch Re-centering & Restart Tracking Fix

Moon epochs re-centered from 3,755 events (89 days, 2 characters)

Offsets had drifted ~85s for Katamba and Yavash due to integer cycle rounding (~0.13s/cycle accumulation). Xibar had drifted -11s.

Moon Old Epoch Center New Epoch Delta
Katamba 1,771,558,797 +85s 1,771,558,712 -85s
Xibar 1,771,560,080 -11s 1,771,560,091 +11s
Yavash 1,771,555,053 +85s 1,771,554,968 -85s

Cycle and visible constants validated -- no changes needed. All Bresenham split ratios match theory within 2.8% (n=481-789 per constant).

Sun model: Spring equinox data (days 50-99, 51.1% accuracy) confirms the v3.5 model ceiling. Brute-force search over 630 formula variants found nothing above 80.1%. The ~20% equinox misses are inherent to cosine models and are handled by the offset correction system.

Restart tracking: offsets now recorded and persisted

Two bugs prevented phase_shift analysis:

  1. log_restart passed nil for offsets, so restart_detected CSV rows had blank offset columns. Now passes all current offsets.

  2. @pre_shutdown_offsets was in-memory only. If the script restarted between shutdown and first post-restart moon event (which always happens), the pre-shutdown data was lost. Now persisted to CharSettings.

60-second periodic window refresh

After disconnect/reconnect, the moon window stayed stale because update_window only emitted XML when the display string changed. Now the cache is invalidated every 60 seconds, ensuring at least one re-emit per minute regardless of game traffic.

Action required: Run ;moonwatch reset on each character after updating.


v4.0.0 (April 2026) -- Bresenham Tick Prediction

Bresenham prediction replaces single-constant prediction

Problem: The pre-v4.0 prediction time_until_set = visible - position always used the integer constant (e.g., 10602 for katamba visible) as if the next event would happen at exactly that offset. But the game quantizes events to 60s tick boundaries, so the actual interval is either 10560 or 10620 — never 10602. Every prediction was wrong by up to 42s.

Solution: Compute the exact 60s tick boundary the game will fire on using Bresenham-style integer arithmetic:

true_set = e_eff + n * cycle + visible
tick_set = nearest_tick(true_set)           # round to nearest 60s
time_until_set = tick_set - game_time       # countdown to actual tick

This predicts the correct quantized event time rather than an impossible average.

New module methods:

  • Moons::TICK_DURATION = 60 — server tick interval constant
  • Moons.nearest_tick(true_time) — rounds to nearest 60s boundary

Constants validated as the game's exact integer-second periods

Split ratio analysis proved that (P mod 60) / 60 matches the observed long/short interval distribution for all six constants (3 moons × cycle + visible):

Constant Value P mod 60 Expected long% Observed long% n
katamba cycle 21089 29 48.3% 47.7% 348
xibar cycle 20848 28 46.7% 46.8% 380
yavash cycle 21130 10 16.7% 17.3% 393
katamba visible 10602 42 70.0% 70.9% 213
xibar visible 10482 42 70.0% 69.9% 236
yavash visible 10624 4 6.7% 6.9% 217

Implication: The game uses integer-second arithmetic (1990s codebase). The Bresenham alternation pattern (e.g., 21060/21120 for katamba cycle) is deterministic — each cycle is floor(P/60) or ceil(P/60) ticks, depending on accumulated phase. No manual recalibration needed; the v3 self-updating constants design is superseded.

Tick-aware offset correction eliminates sawtooth

Problem: Pre-v4.0, the offset correction fired on every observed event, applying a drift correction of 18-42s. This caused a ±30s sawtooth oscillation in offsets — the correction chased the difference between the integer constant and the quantized interval.

Solution: Only correct when the Bresenham tick prediction was wrong (event fired on a different 60s tick than predicted). When the prediction was correct, the offset stays unchanged.

tick_error = (game_time - predicted_tick).abs
if tick_error <= TICK_DURATION / 2
  # Tick prediction correct — no correction needed
else
  # Tick prediction wrong — realign via standard drift correction
end

Expected behavior: Offsets stay constant for long stretches (~100+ cycles between corrections). Corrections only fire when accumulated drift from integer constant rounding pushes a tick prediction across a boundary. Server resets trigger the existing mismatch branch for immediate recalibration.

Removed refresh_server_time — events trigger their own prompt timestamps

Discovery: XML log analysis confirmed that moon and sun events trigger server prompts with fresh server_time values. The v2.8 refresh_server_time function (which issued a time command after each event) was redundant — it received the same timestamp as the event's own prompt, while adding unnecessary game command overhead.

Xibar slowly rises above the horizon.
You sense an overall increase in Lunar mana.
<prompt time="1776646970">&gt;</prompt>        ← event's own prompt (exact tick boundary)
It has been 456 years, 76 days since...       ← unnecessary time command output
<prompt time="1776646970">&gt;</prompt>        ← same timestamp, zero jitter

Analysis of measurement jitter: The mod-60 clustering previously attributed to refresh_server_time jitter (mod 21/22/50 for moons, mod 48/11 for sun) was actually measuring the tick boundaries themselves. The time command added 0-1s of jitter at most, not the 1-30s previously assumed. Transitions between mod classes correlated exactly with server resets (March 4: mod 21→22, April 8: mod 22→50).

Key finding: Moon and sun events fire on different 60s tick grids. Post-April-8: moon ticks at server_time mod 60 = 50, sun ticks at mod 11. This is expected — moon events are aligned to moon epochs, sun events are aligned to calendar rois boundaries (CALENDAR_EPOCH mod 60 = 48, matching pre-reset sun events).

Event handlers now use XMLData.server_time directly. The refresh_server_time function has been removed.

Action required: Run ;moonwatch reset on each character after updating.


v3.5.1 (April 2026) -- Epoch Re-centering Sign Fix

Bug: The v3.5 re-centering applied epoch += center_offset but the correct formula is epoch -= center_offset (since position = (t + offset - epoch) % cycle, subtracting center from epoch absorbs the positive offset bias).

Symptom: After resetting offsets to 0, they immediately jumped to ~153 (Yavash), ~92 (Katamba), ~-66 (Xibar) on the first observed event. The sawtooth oscillated around these wrong centers instead of around 0.

Not a server reset: The April 8 server shutdown captured offsets katamba=117, xibar=-50, yavash=157 -- these are the wrong-sign epoch bias, not a phase shift from the reset.

Moon v3.5.0 Epoch (wrong) v3.5.1 Epoch (correct) Delta
Katamba 1,771,558,907 1,771,558,797 -110s
Xibar 1,771,560,014 1,771,560,080 +66s
Yavash 1,771,555,185 1,771,555,053 -132s

Each delta is exactly -2 * center_offset (correcting the +center that should have been -center).

Action required: Run ;moonwatch reset on each character after updating to clear the accumulated wrong-sign offsets.


v3.5 (April 2026) -- Sun Amplitude Confirmed & Moon Constants Validated

Sun amplitude resolved: 29 -> 29.51

Data: 150 sun observations + 1,700 moon events across days 240-399 and day 0 (summer through winter solstice), from 2 characters (Quilsilgas + Ytterby).

Key observation: Winter solstice data definitively resolved the amplitude:

  • Days 385-398: day_length=7320s (rise_rois=119)
  • Day 399: day_length=7200s (rise_rois=120)
  • Day 0: day_length=7200s (rise_rois=120)
  • Both characters confirm identical values

Constraint derivation: The day 398->399 transition pins the amplitude:

  • round(90 + B × cos(2π×399/400)) >= 119.5 requires B >= 29.504
  • round(90 + B × cos(2π×398/400)) < 119.5 requires B < 29.515
  • B in [29.504, 29.515), so B = 29.51

This resolves all prior "carried forward" amplitude notes:

  • A=29 too low: predicted day_length=7320 at day 0, observed 7200
  • A=30 too high: predicted day_length=7200 at day 389, observed 7320
  • A=29.5 almost works but round(119.4964) = 119 at day 399, not 120
Constant Old New Source
SUN_RISE_AMPLITUDE_ROIS 29 29.51 Solstice constraint [29.504, 29.515)

Model ceiling analysis: Proved that no values of (base, amplitude, phase) can satisfy all 150 observations simultaneously. The game's sun function is not a pure cosine. Accuracy by region:

  • Fall equinox (days 240-300): ~55% -- model predicts transitions 1 day too late
  • Winter approach (days 301-399): ~95% -- flat cosine region, few transitions
  • All misses are exactly +/-1 rise_rois (60 seconds), handled by offset correction

Summer solstice prediction (unobserved, days 1-239 not yet logged):

  • Days 199-201: rise_rois=60, day_length=14400s (~4.0h)
  • Reliable prediction given tight amplitude constraint

Moon constants validated -- cycles unchanged, epochs re-centered

OLS regression on 144-148 unique rises (164-166 cycles) confirms v3.4 cycle values:

Moon v3.4 OLS Cycle v3.5 OLS Cycle Integer Drift/Cycle
Katamba 21,088.68 21,088.67 21,089 -0.28s
Xibar 20,848.19 20,848.19 20,848 +0.19s
Yavash 21,129.64 21,129.63 21,130 -0.37s

Fractional cycles unchanged to <0.01s. Residual std ~17.5s for all three moons. Moon interval prediction accuracy: 100% (all observed intervals land on exactly one of two quantized values per moon).

Epoch re-centering to eliminate offset drift

The v3.4 OLS epochs were optimized for fractional cycle slopes. Since the script uses integer cycle constants, the sub-second rounding error accumulates as offset drift. Analysis of 164 cycles showed offsets had drifted 50-80s from center, producing asymmetric sawtooth patterns (e.g., Yavash cycling 41-91 instead of +-25).

Root cause: Integer cycle constant differs from true fractional cycle by 0.19-0.37s. Each cycle, the offset grows by this amount. Over 164 cycles, Yavash accumulated +66s, Katamba +55s, Xibar -33s.

Fix: Re-anchor epochs to center the sawtooth at 0 based on current offsets.

The correct formula is epoch_new = epoch_OLS - center_offset, because position = (t + offset - epoch) % cycle -- subtracting the center from the epoch absorbs it, making offset=0 the new center.

v3.5.0 had a sign error (epoch += center instead of epoch -= center), which doubled the bias instead of eliminating it. Fixed in v3.5.1.

Moon OLS Epoch Center Correct Epoch Delta New Offset Range
Katamba 1,771,558,852 +55s 1,771,558,797 -55s [-29, +29]
Xibar 1,771,560,047 -33s 1,771,560,080 +33s [-28, +28]
Yavash 1,771,555,119 +66s 1,771,555,053 -66s [-25, +25]

Note: The sawtooth will slowly drift again (~0.2-0.4s/cycle from integer rounding). At this rate, re-centering every ~6 months keeps offsets within +-50s. Re-centered again in v4.0.1 (May 2026) after 6 weeks of drift to ~85s.

Server reset tracking status

57 restart_detected events logged across 17 characters, but 0 shutdown events captured (no character observed the shutdown announcement). Without pre/post offset comparison, phase shifts cannot be measured. The only prior data point remains the manually analyzed March 4 reset (Xibar +73s, Katamba 0s, Yavash -11s). Need 3-5 resets with both shutdown and restart captured to draw conclusions.


v3.4 (March 2026) -- Epoch Re-anchoring & Visible Duration Tuning

All three moon epochs re-anchored; Xibar and Yavash visible durations refined

Method: OLS regression on all rise timestamps with gap-corrected indices, extended dataset from v3.3 (155 cycles vs 126). Xibar included for the first time -- it was skipped in v3.3 due to near-zero drift at the time, but had since accumulated +2.8s/event drift from its stale epoch.

Data: 1,599 moon events + 596 sun events across days 240-392 (summer through winter). 134-140 unique rise timestamps per moon over 155 cycles, confirmed identical across both characters (Quilsilgas + Ytterby).

Moon Param Old New Regression Value Method
Xibar epoch 1,771,539,275 1,771,560,047 OLS intercept Re-anchored (+20,772s -- ~1 full cycle off)
Xibar visible 10,481 10,482 10,481.8 Mean of 224 rise-to-set pairs
Yavash epoch 1,771,555,097 1,771,555,119 OLS intercept Re-anchored (+22s)
Yavash visible 10,623 10,624 10,623.8 Mean of 204 rise-to-set pairs
Katamba epoch 1,771,558,832 1,771,558,852 OLS intercept Re-anchored (+20s, centers offset range from [1,55] toward [-19,35])

No cycle constant changes -- all three confirmed at current values:

Moon OLS Cycle Integer Drift/Cycle Interval Distribution
Katamba 21,088.68 21,089 -0.24s 21060 x172 / 21120 x154
Xibar 20,848.19 20,848 +0.15s 20820 x194 / 20880 x167
Yavash 21,129.64 21,130 -0.32s 21120 x271 / 21180 x57

Residual analysis (all three moons, 155 cycles):

Moon Residual Std Residual Range Drift (first 10 vs last 10)
Katamba 17.6s [-30, +30] +9.1s
Xibar 17.4s [-29, +31] -4.6s
Yavash 17.4s [-30, +30] +2.4s

Root cause of Xibar drift: The v3.3 regression re-anchored Katamba and Yavash epochs but skipped Xibar. Over time, the stale epoch accumulated ~1 full cycle of error (20,772s), which the offset system absorbed -- explaining why Xibar offsets consistently hovered at 18-77s while Katamba and Yavash stayed near 0.

Sun amplitude: Resolved in v3.5. See v3.5 changelog entry.


v3.3 (March 2026) -- OLS Regression Recalibration

Katamba and Yavash constants re-derived via linear regression

Method: OLS regression on all rise timestamps with gap-corrected indices, cross-validated across both characters (Quilsilgas + Ytterby). The regression minimizes cumulative drift across the full observation window, unlike the previous weighted-average approach which was sensitive to local noise.

Data: 1,977 total events (moon + sun) across days 240-363 (summer through winter). 112 unique rise timestamps per moon over 126 cycles, confirmed identical across both characters to within measurement noise (residual std ~17.5s from rois quantization).

Moon Param Old New Regression Value Method
Katamba cycle 21,088 21,089 21,088.67s OLS on 112 rises, 126 cycles
Katamba visible 10,601 10,602 10,601.7s Mean of 105 rise-to-set pairs
Katamba epoch 1,771,537,811 1,771,558,832 OLS intercept Re-anchored to new cycle
Yavash cycle 21,132 21,130 21,129.62s OLS on 112 rises, 126 cycles
Yavash visible 10,625 10,623 10,623.5s Mean of 104 rise-to-set pairs
Yavash epoch 1,771,533,944 1,771,555,097 OLS intercept Re-anchored to new cycle
Xibar - - - 20,848.x No change needed (near-zero drift)

Residual analysis (with new integer constants):

Moon Cycle Rise Residual Std Rise Residual Range Drift (first 10 vs last 10)
Katamba 21,089 21.4s [-45, +49] -35.5s over 126 cycles
Yavash 21,130 22.0s [-45, +44] -41.0s over 126 cycles

Both moons show mild negative drift with the rounded-up integer cycle, which is expected since the true fractional cycles (21,088.67 and 21,129.62) are below the integer values. This drift rate (~0.3s/cycle) is well within the 60s rois quantization band and far better than the previous constants:

  • Yavash old (21,132): was drifting +5.8s/event, offset climbed from ~64 to ~279
  • Katamba old (21,088): was drifting -0.55s/event, offset walked from 0 to -55

Interval distributions confirm the game's rois quantization pattern:

Moon Low Interval High Interval Split Implied Fractional Cycle
Katamba 21,060s x54 21,120s x50 52/48 ~21,089
Yavash 21,120s x82 21,180s x18 82/18 ~21,131

Sun amplitude: Resolved in v3.5. See v3.5 changelog entry.


v3.2 (March 2026) -- Yavash Cycle Correction

Yavash cycle constant: 21,131 -> 21,132

Analysis: 1,191 moon events across 679 hours (2 characters) revealed systematic offset drift:

Moon Offset Drift Drift/Cycle Action
Katamba -25s / 679h -0.21s/cycle No change (negligible)
Xibar +44s / 679h +0.38s/cycle No change (tolerable)
Yavash +111s / 679h +0.96s/cycle 21131 -> 21132

Yavash cycle distribution: 21120 (83%) / 21180 (17%), weighted avg 21130.3. The +0.96s/cycle drift means predictions arrive ~1s early each cycle, accumulating to 100+ seconds of offset over a few weeks. Changing to 21132 reduces drift to ~0.04s/cycle (negligible).

Sun amplitude: Resolved in v3.5. B=29.51, constrained to [29.504, 29.515) by winter solstice data. See v3.5 changelog entry.


v3.1 (March 2026) -- Epoch Recalibration & Rois-Level Sun Model

CALENDAR_EPOCH recalibrated: 1,688,607,203 -> 1,688,607,948 (+745s)

Discovery: Analysis of 82 sun events revealed that all sunrise/sunset times land on exact rois (60-second) boundaries when the epoch is shifted +745s. The previous epoch had accumulated 799s of calendar error -- it reported rois 26 when the game said rois 12.

Verification: Two "positive" (exact) in-game TIME readings at prompt-tagged server times:

  • server_time 1773308202: game says rois 20, epoch gives 20.9 -> floor 20 ✓
  • server_time 1773308339: game says rois 23, epoch gives 23.18 -> floor 23 ✓

Root cause: The original epoch was derived from a single approximate TIME reading ("Rois ~15"), then a -380s "recalibration" in 2025-02 made it worse (moved from 7 to 14 rois error). The sun event data (82 observations all aligning to rois boundaries) provided a much stronger calibration signal than any single TIME reading.

Sun formula rewritten: 120s-bucket -> rois-level model

Discovery: The game does NOT quantize day lengths to 120-second buckets. It quantizes sunrise and sunset independently to individual rois (60s). The apparent 120s day-length steps are a consequence of both rise and set each stepping by 60s in the same direction.

Key findings from sun event analysis:

  1. All rise/set times are exact multiples of 60s (with corrected epoch)
  2. rise_seconds + set_seconds = 21600 always (perfect midday symmetry)
  3. Rise step sizes: always exactly 0 or 60 seconds between consecutive days
  4. Each rois level lasts 2-4 days (sinusoidal dwell pattern)

New formula:

rise_rois = round(90 + 29 × cos(2πd/400))
set_rois  = 360 - rise_rois
rise_seconds = rise_rois × 60
set_seconds  = set_rois × 60

Old constants removed: SUN_DAY_LENGTH_BASE, SUN_DAY_LENGTH_AMPLITUDE, SUN_PHASE_SHIFT, SUN_QUANTUM New constants: SUN_RISE_BASE_ROIS = 90, SUN_RISE_AMPLITUDE_ROIS = 29, ROIS_PER_DAY = 360

Accuracy: 82% exact rois match (66/81 observed days). Offset correction handles the rest. Amplitude resolved in v3.5: B=29.51. See v3.5 changelog entry.

Moon constants drift-corrected (437 events over 483 hours)

Moon Param Old New Method
Katamba cycle 21090 21088 Weighted avg 21087.4, +139s drift/483h
Katamba visible 10590 10601 Weighted avg 10601.1 (32/68 split)
Xibar cycle 20850 20848 Weighted avg 20847.9, +253s drift/483h
Xibar visible 10484 10481 Weighted avg 10480.6 (32/68 split)
Yavash cycle 21129 21131 Weighted avg 21130.9, -71s drift/483h
Yavash visible 10625 10625 Stable (no change)

Drift correction method: The net offset drift over 483 hours reveals whether a constant is slightly too high (positive drift = predicted early) or too low (negative drift = predicted late). The corrected values eliminate ~1-3s/cycle systematic drift.


v2.14 (March 2026)

  • Server reset tracking scaffolding: Implemented comprehensive reset detection and logging
  • Shutdown detection: New SHUTDOWN_PATTERN catches "DragonRealms will be shutting down" messages
  • Gap detection: ServerResetTracker class detects restarts via event gaps exceeding cycle + 5min buffer
  • CSV logging: New server_resets_<CharName>.csv captures:
    • shutdown: Pre-reset offset snapshot when shutdown announced
    • restart_detected: When gap indicates missed events (with gap duration, trigger moon)
    • phase_shift: Post-restart offset comparison showing shifts per moon
  • Phase shift alerts: Significant shifts (>60s) generate user-visible messages
  • Pre/post comparison: Offsets captured at shutdown are compared to post-restart values
  • SOLID architecture: ServerResetTracker has single responsibility, follows existing patterns

v2.13 (March 2026) -- superseded by v3.1 rois-level model

  • Sun formula overhaul: Reverse-engineered game formula from Elanthipedia reference and observed data
  • Quantization discovery: Identified 120-second day-length steps (later found to be a consequence of independent 60s rise/set quantization -- see v3.1)
  • Sun constants (removed in v3.1): SUN_DAY_LENGTH_BASE, SUN_DAY_LENGTH_AMPLITUDE, SUN_PHASE_SHIFT, SUN_QUANTUM
  • Claimed accuracy: 96.3% on 54 days, but re-evaluation on 78 days showed ~73% with quantized predictions
  • Server reset analysis: Documented March 4 reset impact on moon phases
    • Xibar shifted +73s (significant)
    • Katamba unchanged
    • Yavash shifted -11s (within noise)
  • Moon quantization analysis: Investigated applying sun-style quantization to moons
    • Finding: Moon quantization is non-deterministic (depends on cumulative phase)
    • Conclusion: Current weighted-average + self-correction approach is optimal

v2.12 (February 2026)

  • Expanded dataset analysis: Recalibrated from 334 moon events (2 characters, days 240-271)
  • Weighted average methodology: For non-50/50 distributions, use weighted average instead of midpoint or dominant value
  • Xibar visible duration fix: Changed 10470 -> 10484 (+14s)
    • Distribution: 10440 (27%), 10500 (73%)
    • Weighted avg: 0.27×10440 + 0.73×10500 = 10484
  • Yavash cycle fix: Changed 21120 -> 21129 (+9s)
    • Distribution: 21120 (85%), 21180 (15%)
    • Weighted avg: 0.85×21120 + 0.15×21180 = 21129
    • v2.11 used dominant value which caused slow +9s/cycle drift
  • Yavash visible duration fix: Changed 10620 -> 10625 (+5s)
    • Distribution: 10620 (91%), 10680 (9%)
    • Weighted avg: 0.91×10620 + 0.09×10680 = 10625
  • Katamba unchanged: Both cycle (21060/21120) and visible (10560/10620) are true 50/50, midpoint remains optimal
  • Xibar cycle unchanged: 20820/20880 at 56/44 is close enough to 50/50, midpoint 20850 remains optimal

v2.11 (February 2026)

  • Yavash cycle constant fix: Changed from 21150 -> 21120
  • v2.9 assumed 50/50 distribution between 21120/21180 (midpoint theory)
  • Actual distribution from 32 observations: 78% at 21120, 22% at 21180
  • Using midpoint caused +16.8s net drift per cycle, accumulating to 300-450s over time
  • Now using dominant value (21120) -- expect occasional -60s correction when 21180 occurs
  • Katamba and Xibar remain at midpoints (their distributions are truly ~50/50)

v2.10 (February 2026)

  • Removed drift threshold: Previously, only drifts under 360s (6 minutes) were corrected; larger drifts were ignored
  • The threshold was a holdover from Firebase sync era to protect against stale cross-client data
  • In pure-observation model, every event is freshly witnessed - no stale event concern
  • Now all observed drift is corrected immediately, regardless of magnitude
  • This fixes cases where large prediction errors (e.g., 828s) went uncorrected
  • Moon constant recalibration (from pre-20250220 CSV data):
    • Katamba: visible 10620->10590 (observed 10560/10620, midpoint 10590)
    • Yavash: visible 10650->10620 (observed 10620 only)

v2.9 (February 2026)

  • 60-second server tick discovery: Identified that the game server fires moon events on 60s tick boundaries
  • All observed intervals are exact multiples of 60s (e.g., 20820/20880 for xibar, never 20850)
  • This explains why offsets were oscillating/drifting despite second-level timestamp precision
  • Constant recalibration: Set all constants to midpoints of observed 60s bands:
    • Katamba: visible 10601->10620 (cycle unchanged at 21090)
    • Xibar: cycle 20852->20850, visible 10482->10470
    • Yavash: cycle 21129->21150, visible 10623->10650
  • Midpoint values center the +-30s tick jitter around zero, preventing systematic drift

v2.8 (February 2025) — removed in v4.0

  • Timestamp refresh on events: Moon/sun event handlers now issue a time command to force a fresh XMLData.server_time before capturing the timestamp
  • Previously, timestamps could be minutes stale if the player was idle (prompts only arrive with commands)
  • Now timestamps are accurate to within the command round-trip latency (~100-500ms)
  • This should significantly reduce the 20-30s standard deviation in calibration measurements
  • v4.0 note: XML log analysis proved the initial assumption was wrong — events DO trigger prompts with fresh timestamps. The time command was redundant (zero jitter). Removed in v4.0.

v2.7 (January 2025)

  • Sun event logging: New sunwatch_events_<CharName>.csv captures sunrise/sunset intervals
  • Logs day_interval, day_length, night_length, expected_day_length, and drift
  • Validates CALENDAR_EPOCH accuracy and sinusoidal day length formula
  • Constant recalibration: Updated from 443 observations (days 53-135)
    • Katamba: cycle 21084->21090, visible 10608->10601
    • Yavash: cycle 21133->21129, visible 10632->10623
    • Xibar: unchanged (within 3s of observed)

v2.6 (January 2025)

  • Per-character storage: All offsets now stored in CharSettings instead of GameSettings
  • Auto-reset: Offsets exceeding +-1800s are automatically reset to 0
  • Raw interval logging: CSV now logs cycle_interval, visible_dur, hidden_dur instead of model-relative drift
  • Logging toggle: New log/nolog arguments (per-character, persistent)
  • Code cleanup: Removed redundant SEASONS and TIMES_OF_DAY arrays; methods now return strings directly

v2.5 (December 2024)

  • Firebase data guards: staleness check and local observation preference

v2.4 (December 2024)

  • Constant calibration from moondata.2024 analysis

v2.3 (December 2024)

  • Initial moon event logging (with drift column)

v2.2 (December 2024)

  • Drift detection for timing corrections

v2.1 (December 2024)

  • Sun tracking and DRTime module

v2.0 (December 2024)

  • Epoch-based moon calculations, replacing Firebase-dependent approach

Clone this wiki locally