New modern Znote AAC version compatible 8.1 - 8.5 PHP and TFS 1.1-1.6 // Canary

Migrate Znote AAC to PHP 8.1 – 8.5 (release 2.0.0)​

Summary​

This PR brings the entire codebase forward from PHP 7.x to modern PHP. It is a prerequisite for
deploying on current hosting, where PHP 7.4 is long gone and even 8.1 is being retired.

The site did not run on PHP 8 before this change β€” it died with a fatal error on every single
page load. That is fixed here, along with the full set of PHP 8.0–8.5 behaviour changes,
plus several genuine bugs and two security holes found while verifying the migration.

Minimum PHP is now 8.1. PHP 8.0 and older are rejected at startup.

Version bumped 2.0_DEV β†’ 2.0.0.


Critical β€” the site was broken before this PR​

Cannot redeclare elapsedTime() β€” fatal on every page load​

elapsedTime() was declared in both engine/init.php and engine/database/connect.php. Since
init.php requires connect.php, PHP fatally errored on every request. Consolidated into a single
guarded definition in connect.php, with the page start time shared via
$GLOBALS['__znote_start_time'] so the timer stays correct for entry points that load connect.php
without init.php (ipn.php, paygol_ipn.php).

Also removed dead timing code in init.php and fixed engine/footer.php, which referenced an
undefined $start.

mysqli silently stopped reporting errors​

PHP 8.1 changed the default mysqli_report() mode to MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT.
mysqli now throws mysqli_sql_exception instead of returning false, which meant:

  • every if ($result === false) check in connect.php was unreachable dead code, and
  • any SQL error became an uncaught exception β€” a blank HTTP 500 with no logging.
connect.php now sets the report mode explicitly (so behaviour is identical on 8.1 through 8.5
regardless of host php.ini) and catches the exception inside the four query wrappers, restoring
the false-returning contract the rest of the codebase depends on while keeping error_log()
diagnostics. Result sets are now freed explicitly.

forum.php began with a blank line before <?php​

Output was flushed before ob_start() ran, so headers were already sent and every header()
redirect in the forum silently failed. Leading newline stripped.


PHP 8.x compatibility fixes​

Fatal in PHP 8.0​

  • count(): Argument #1 ($value) must be of type Countable|array, false given in
    forum_search.php. mysql_select_multi() returns array|false, and count() on false became
    a TypeError in PHP 8. This crashed the page whenever a forum search matched nothing β€” a
    trivially reachable path. Added array normalisation at all 7 query sites in that file. Output
    behaviour is unchanged (an empty array still renders "No results.").

Deprecated in PHP 8.1, fatal in PHP 9​

  • "Automatic conversion of false to array is deprecated"in three places:
    • engine/init.php β€” $user_data['premdays'] = ... when user_data() returned false. This
      fired on every page for any logged-in session whose account row failed to load, spraying
      deprecation notices site-wide.
    • forum.php β€” $guildboard[] = ... where $guildboard was initialised to false.
    • house.php β€” $house['points'] = ... on a non-existent house.

Deprecated in PHP 8.1​

  • Passing null to non-nullable internal parameters. Hardened sanitize(), is_admin() and
    mysql_znote_escape_string() to be null-safe, and fixed a base_convert(null, ...) in the
    Base32 decoder (engine/function/rfc6238.php) that was previously masked with @.
  • 88 unguarded superglobal reads across 18 files β€” getValue($_GET['x']) / getValue($_POST['x'])
    on keys that may not exist. All now ?? null. These were Undefined array key warnings in PHP 8
    (notices in 7.x) and fed null straight into string functions.

Deprecated in PHP 8.4​

  • Implicitly nullable parameter type β€” function VerifyPaypalIPN(array $IPN = null) in
    ipn.php β†’ ?array.

Swept and confirmed clean​

No use of anything removed or deprecated across 8.0–8.5: create_function, each(),
money_format, ereg*, split(), mysql_*, FILTER_SANITIZE_STRING, ${} string
interpolation, curly-brace string offsets $str{0}, strftime/gmstrftime, utf8_encode/
utf8_decode, date_sunrise/date_sunset, libxml_disable_entity_loader, E_STRICT,
xml_set_object, mysqli_ping, get_class() with no arguments, (real)/(unset) casts,
swapped implode() argument order, nested ternaries without parentheses, required-after-optional
parameters, and dynamic property creation.


Security fixes​

PayGol IPN secret was never actually validated​

paygol_ipn.php compared the incoming secret against $paygol['secret'], but config.php defines
the key as secretKey. The missing key evaluated to null, and with no secret in the request
the comparison false != null was itself false β€” so the 403 guard never triggered and anyone
could forge a shop-points credit callback
. Now reads secretKey.

Forum reply did not verify character ownership​

forum.php took reply_cid straight from $_POST and used it as the posting character without
checking it belonged to the logged-in account. Now validated against the session's own character
list before the post is accepted.


Bug fixes​

  • guilds.php β€” $gid = (int)$guild['id']; if ($gid === false) could never be true, because
    (int) never yields false. A request for a non-existent guild fell straight through the
    redirect guard instead of bouncing to guilds.php. Now checks the row before casting.
  • house.php β€” house.php?id=<nonexistent> (reachable by any visitor) walked into a chain of
    warnings and a PHP-9 fatal. Now renders a proper "House not found." page. Also guards the bidder
    lookup against a missing player row.
  • api/api.php β€” UseClass() resolved class files relative to the current working directory,
    so any API module invoked directly (rather than from api/) failed to load its class. Now
    resolved against __DIR__. Pre-existing upstream bug; api/modules/base/player/test.php was
    broken because of it and now works.
  • shop.php β€” $_SESSION['shop_session'] is only written at the bottom of the page, so the
    first POST read it undefined. Worse, null == null meant a purchase could pass the anti-replay
    check. Now requires the session value to actually be set.
  • pagseguro_ipn.php / pagseguro_retorno.php β€” a failed or malformed PagSeguro API response
    made simplexml_load_string() return false, after which the code read properties off false
    and wrote a bogus row / ran an UPDATE with an empty transaction code. Both now bail out.
  • changepassword.php β€” the required-fields loop iterated $_POST, so a missing field was
    never caught, only an empty one. Rewritten to check the required list directly, with the account
    and salt lookups guarded.
  • helpdesk.php, forum.php, credits.php, monster_loot.php, highscores.php,
    myaccount.php
    β€” assorted undefined variables and unguarded reads of query results that may
    be false.

Documentation​

  • README rewritten: removed the stale CodeFactor badge and Branch: v2 note, repointed downloads
    to Open Games Community, and documented the project as a maintained continuation of the upstream
    Znote AAC (unmaintained for ~5 years).
  • Added explicit Version and Requirements sections with a per-version support table.
  • Aligned the version guard in api/api.php with engine/init.php (both were inconsistent β€” one
    said 7.2, the other 8.1).

Verification​

All 117 PHP files were linted and executed on five real PHP builds:

PHPBuildLintExecution
8.18.1.33117/117clean
8.28.2.33117/117clean
8.38.3.4117/117clean
8.48.4.25117/117clean
8.58.5.10117/117clean
Execution used a harness that stubs mysqli/curl and traps everything under E_ALL, covering
every page, layout fragment, widget, API endpoint and special/ maintenance script across
10 scenarios: guest / logged-in / admin Γ— normal / POST-submitted Γ— queries-returning-rows /
queries-returning-nothing
.

The empty-result-set scenarios are what surfaced most of the bugs above β€” they exercise the
"row not found" paths that only appear in production when a record is missing or deleted.

Results were byte-identical across all five PHP versions, with zero warnings, deprecations
or exceptions.


Notes for reviewers​

  • PHPMailer is not vendored. It never was β€” the README documents it as a separate download
    extracted to PHPMailer/. Registration and account-recovery email will fatal until it is added.
    Out of scope for this PR, but worth knowing before deploying.
  • Not fixed, deliberately: helpdesk.php echoes the account name and email into HTML attributes
    without escaping. That is an XSS vector, but changing output escaping is a separate concern from
    a version migration and deserves its own PR.
  • Watch for string/number comparison. PHP 8.0 changed == between strings and numbers
    (0 == "abc" is now false). No tool can flag this reliably and the codebase compares
    DB values loosely in many places, so login and shop flows deserve a manual pass after deploy.
  • Runtime behaviour was verified against stubbed queries, not a live database. A smoke test against
    a real TFS 1.4 schema before going to production is recommended.

Server compatibility documented accurately​

The README previously implied per-version TFS support the code does not implement.
$config['ServerEngine'] has only four values (OTHIRE, TFS_02, TFS_03, TFS_10) and the
entire TFS 1.x line shares the single TFS_10 path β€” there is no per-minor-version branching.
Compatibility within 1.x is therefore a schema question, not a code question.

Verified against the real upstream schemas from otland/forgottenserver:

  • TFS 1.2 / 1.4 / 1.4.1 / 1.4.2 β€” supported. 1.4 is the target.
  • TFS 1.1 β€” same code path, expected to work, untested.
  • TFS 1.0 β€” not supported. The TFS_10 path queries players_online and guild_membership,
    neither of which exists in 1.0 (it used players.online and players.rank_id).
  • TFS 1.6 β€” not supported. Diffing the 1.4 and 1.6 schemas shows players.lastip and
    ip_bans.ip changed from int unsigned to varbinary(16). Znote writes both as numeric longs
    (sprintf('%u', ip2long($ip))), so IP logging and the IP-ban insert in
    engine/function/users.php would write values the server cannot match. 1.6 also adds
    player_outfits / player_mounts tables and mount columns on players.
  • Canary / OTServBR-Global β€” not supported; no Canary code path exists anywhere.
Note that TFS 1.3, 1.5 and 1.8 are not upstream releases. The otland tags are 1.0, 1.1, 1.2,
1.4, 1.4.1, 1.4.2 and 1.6. "TFS 1.5" refers to a community downgrade fork.

TFS 1.6 and Canary support are plausible follow-ups and would mean adding new ServerEngine
values rather than widening TFS_10.


New: TFS 1.6 and Canary support​

Two new ServerEngine values were added: TFS_16 and CANARY.

Approach​

Rather than edit the ~50 existing ServerEngine === 'TFS_10' comparisons (high risk of missing
one), engine/init.php and api/api.php record the configured engine in
$config['ServerEngineReal'] and then normalise $config['ServerEngine'] to TFS_10 for the two
new engines. Every existing 1.x branch therefore keeps working untouched, and divergences are
handled by explicit helpers in engine/function/general.php:

serverEngineReal(), engineIsCanary(), engineIsTFS16(), accountField(),
accountFieldList(), houseCol(), houseSelect(), sqlIpWrite(), sqlIpSelect().

TFS 1.6​

The only incompatibility is a column type change: players.lastip and ip_bans.ip went from
int unsigned to varbinary(16). Writes now go through INET6_ATON() and reads through
INET_ATON(INET6_NTOA()). Affects character creation (web + API) and the IP-ban path.
Diffing the 1.4 and 1.6 schemas confirmed there are no missing tables or columns otherwise.

Canary / OTServBR-Global​

Four divergences, all handled:

AreaCanaryHandling
Premiumno accounts.premium_ends_at; uses premdays + lastdayaccountField() selects (lastday + premdays*86400) AS premium_ends_at; user_account_add_premdays() increments premdays
2FAno accounts.secrettwoFactorAuthenticator force disabled; accountField('secret') yields NULL AS secret; the ungated 2FA-removal branch in recovery.php is skipped
House auctionsinternal_bid, bid_end_date, highest_bid, bidderhouseCol() / houseSelect() map and alias them, so the PHP array keys are unchanged
Global storageno world_id columnseparate INSERT without it
Character creation needed no changes β€” the three players columns Canary lacks (online,
rank_id, guildnick) were already unset() on the TFS 1.x path.

Verification​

A schema guard was added to the test harness: the stubbed mysqli validates every executed
query
against the target server's real schema.sql, which catches dynamically-built SQL that
static analysis cannot see. The full 5-scenario page matrix was then run three times, once per
engine, against the matching schema:

EngineSchemaNew violations vs TFS 1.4 baseline
TFS_10forgottenserver 1.4baseline
TFS_16forgottenserver 1.60
CANARYcanary main0
Engine helper output was additionally unit-checked per engine, confirming e.g. that TFS_16
emits INET6_ATON('203.0.113.45') and CANARY emits
`internal_bid` AS `bid` and the premdays-based premium update.

config.php is unchanged apart from documenting the new options; the default remains TFS_10.


Payment gateways audited and fixed​

All three gateways (PayPal, PagSeguro, PayGol) were traced end to end: config keys vs code usage,
schema tables, and the credit-the-points flow.

Missing PagSeguro tables (blocker)​

znote_pagseguro and znote_pagseguro_notifications were used by pagseguro_ipn.php and
pagseguro_retorno.php but created by nothing β€” not znote_schema.sql, not any Lua script.
PagSeguro could never have worked on a fresh install; every insert failed.

Both tables are now in engine/database/znote_schema.sql. The definitions are not invented β€” they
were already documented in the comment block at the top of pagseguro_retorno.php; they have been
added with the file's own conventions (ENGINE=InnoDB, no FK) plus an index on transaction,
which every lookup uses.

PayPal: duplicate transactions were never rejected​

$txn_id_check = mysql_select_single("SELECT `txn_id` FROM `znote_paypal` WHERE `txn_id`='$txn_id'");
if ($txn_id_check !== true) { // <-- always true

mysql_select_single() returns array|false and never returns true, so the guard could not
fire. Any PayPal IPN could be replayed and credited repeatedly β€” and PayPal legitimately retries
IPNs, so this could double-credit without an attacker. Changed to === false so the payment is
processed only when the transaction has not been seen before.

PayGol: no idempotency at all​

There was no check that a message_id had already been credited, so a retried or replayed callback
credited points again. Added a message_id lookup against znote_paygol before crediting.

(The PayGol secret-key check was fixed earlier in this PR β€” it compared against $paygol['secret']
while config defines secretKey, so the 403 guard never fired.)

CURLOPT_BINARYTRANSFER β€” deprecated in PHP 8.4​

ipn.php set CURLOPT_BINARYTRANSFER, which has been a no-op since PHP 5.5 and is deprecated as
of PHP 8.4
, so it would emit a deprecation notice on the versions this release targets. Removed.

This one was invisible to the rest of the verification: the test environment has no curl extension,
so no curl constant is ever evaluated there. It was found by reading the gateway code directly.

Unguarded row reads on all three gateways​

Every gateway did $row['column'] on the result of a mysql_select_single() that can return
false (missing znote_accounts row, failed lookup). On PHP 8 that is a warning and the points
maths silently treats the balance as 0. All now check is_array() first, and PayPal/PagSeguro log
the anomaly to their transaction tables instead of silently miscrediting.

Verified​

  • All 117 files lint clean on PHP 8.1 / 8.2 / 8.3 / 8.4 / 8.5.
  • Full page matrix clean on 8.3 / 8.4 / 8.5.
  • Schema guard run over the whole site against all three server schemas: TFS_10 (1.4),
    TFS_16 (1.6) and CANARY β€” 11 findings each, zero delta between engines, and all 11 are
    pre-existing or known false positives (see below).

Remaining schema findings (all pre-existing, none fixed)​

FindingVerdict
onlinetime* on znote_players (toponline.php)Opt-in β€” the ALTER TABLE ships with Lua/TFS_10/globalevent powergamers/powergamers.lua
player_skills (special/repairSkills.php)TFS 0.x maintenance script, manually invoked
accounts.premium_points (special/convertoldshoppoints.php)TFS 0.x conversion script, manually invoked
aliases p / za, table ipFalse positives in the guard's regex (SQL aliases; the literal text "from IP:" in a log message)
Author
Alex
Downloads
0
Views
1
First release
Last update
Rating
5.00 star(s) 1 ratings
Top