Skip to content

Porting BASIC between MV systems

Gordon Heydon edited this page Sep 24, 2026 · 41 revisions

Porting BASIC between MV systems

One rule underpins everything below:

Compile and run on every target before trusting a change. Each platform accepts things the others refuse, so a single-platform green is not evidence.

This page records differences that have actually cost time, with the evidence for each. It covers MVX, Rocket UniData 8.3, Rocket UniVerse 14.2 and jBASE 6.2.

The three failure classes

They are not equally dangerous, and the ordering matters when you are deciding how much to test:

  1. Refuses to compile. Cheap: you find out immediately.
  2. Compiles, then misbehaves. Expensive, but a test will catch it.
  3. Compiles everywhere and differs silently. Worst, because nothing fails anywhere — you get a wrong answer, or a protocol that stalls with no error.

Most of this page is class 2 and 3.


1. Reserved words

OUT is reserved on jBASE

Verified on jBASE 6.2.1.1:

      SUBROUTINE TOUT(OUT)
      OUT = 1
      RETURN
"TOUT",  line 1 (offset 13)  near RESERVED WORD "OUT":

The same source with the parameter renamed GOUT compiles clean. OUT is fine on MVX, UniData and UniVerse, so this only appears when jBASE first compiles the code — by which time the name may be spread over dozens of files. mv_git carried OUT in 22 BP/ items and had to rename all of them.

If you are writing shared code, do not name anything OUT.

LN is reserved on jBASE

LN is the natural-log function, so LN = DCOUNT(X, @AM) is a syntax error — and the compiler reports the NEXT line as a second error too, so one bad name reads like two faults:

"MVPKGOS",  line 383 (offset 2)  near RESERVED WORD "LN":
"MVPKGOS",  line 391 (offset 2)  near RESERVED WORD "NEXT":
2 errors were found

jBASE reserves a lot of ordinary names

OUT is not special. Verified on 6.2.1.1, every one of these fails as a variable or parameter name:

name seen in
OUT 22 files in mv_git
SUB SUBROUTINE X(SUB, …)
SENTENCE SUBROUTINE X(SPEC, SENTENCE, SKIP)
STATUS STATUS = 6
KEY KEY = FIELD(S, " ", 3) — GIT.CONFIG, GITCONFIG, GIT.ATTR
DIR GIT.CLONE line 46, GITCLONE line 2
COUNT GITLOG line 2 — note this is also a function on every MV system
DATA GIT.OSFILE line 32
LN MVPKG.INSTALL, MVPKG.REMOVE — jBASE's natural-log function
NEG MVPKG.INSTALL — jBASE's negate function

The message names the word, which makes these cheap once you are compiling on jBASE at all:

"GIT.CMD.ADD",  line 20 (offset 12)  near RESERVED WORD "SUB":
SUBROUTINE GIT.CMD.ADD(SUB, DESC, HANDLER)
           ^
	syntax error

The cost is in when you find out. All six compile fine on MVX, UniData and UniVerse, so they accumulate freely until jBASE first sees the code — in mv_git they had reached 16 programs, and because the catalog step discarded its output the failures were invisible: the suite reported a clean run while a third of the verb's subroutines were simply absent from the library.

If you are writing BASIC that may reach jBASE, treat this as a naming rule rather than a debugging exercise.

SETTING is reserved on UniData — and it reserves it as a label too

LOCATE ... SETTING makes SETTING a keyword, so a GOSUB label named for what the routine does is a compile error on UniData while compiling clean on MVX:

main program: syntax error at or before
<line 23>       GOSUB SETTING
          ------------------^
Misuse of reserved word 'SETTING'
Expecting: number,variable

main program: syntax error at or before
<line 61> SETTING:
          ------^
Misuse of reserved word 'SETTING'

Both the GOSUB and the label itself are reported, so — as with jBASE's LN — one bad name reads as two faults. Worth noting because the usual advice is to watch variable names: a label is just as exposed, and label names tend to be plain English verbs (SETTING, STATUS, DATA) which is exactly the vocabulary these dialects keep for themselves.

Found in MVPKG.CONFIG (mv_package#30), which had compiled on MVX first.

$IFDEF on a symbol you never included is silently false

$IFDEF asks the preprocessor about a symbol, and PLATFORM.H is where the build declares them. A source that guards a branch with $IFDEF ENGINE but never says $INCLUDE BP.INC PLATFORM.H does not fail to compile — it quietly compiles the $ELSE arm. Everywhere.

This is the most expensive bug shape in this document, because the compiler that is easiest to develop against can hide it. mvx-basic predefines MVX and ENGINE, so on MVX the guarded branch is taken regardless of what the source says. On UniData and UniVerse $ELSE happens to be the correct answer, so nothing looks wrong there either.

In mv_git, fifteen handlers were in this state. It surfaced only when jBASE became the first platform where ENGINE arrives only through PLATFORM.H — and there all fifteen took a dispatch path with no arms for them, so most of the verb answered unknown git-object operation.

Two rules:

  • Any source that uses $IFDEF must include the header that declares the symbols. No exceptions — a guard reading an undeclared symbol is not a guard.
  • Test it structurally, because at runtime it is invisible on the platforms where $ELSE is right anyway:
for f in BP/*; do
    grep -q '$IFDEF' "$f" || continue
    grep -q 'INCLUDE BP.INC PLATFORM.H' "$f" || echo "unguarded: $f"
done

Compiler-builtin defines are a convenience that becomes a trap the moment a second implementation exists. Prefer the header on every platform, including the one that does not need it.

CONVERT's arguments are in a different order on jBASE

The three-argument CONVERT function is not the same function everywhere:

platform signature
MVX, UniData, UniVerse CONVERT(from, to, expr)
jBASE CONVERT(expr, from, to)

Both compile on both. Neither warns. The wrong order simply returns something plausible, which is the worst possible failure for a data-transformation call.

Measured on 6.2.1.1 — converting attribute marks to newlines:

S = "aa" : CHAR(254) : "bb"
X = CONVERT(CHAR(254), CHAR(10), S)   ;* jBASE: returns CHAR(254). One byte.
Y = CONVERT(S, CHAR(254), CHAR(10))   ;* jBASE: "aa" LF "bb".  Correct here.

In mv_git this converted LF to SRC inside the single character AM and handed that character back, so a whole attribute record staged as two bytes — an attribute mark and a newline. Nothing was ever declared, every later read fell back to live values, and twelve assertions failed in ways that all pointed at the wrong place.

Guard it, do not guess:

$IFDEF JBASE
      OUT = CONVERT(SRC, AM, LF)
$ELSE
      OUT = CONVERT(AM, LF, SRC)
$ENDIF

The statement form is not the escape. CONVERT chars TO chars IN var works on jBASE and on U2, but MVX rejects it — expected '=' in assignment. So there is no single spelling of CONVERT, function or statement, that compiles and behaves on all four.

Often you do not want CONVERT at all. The commonest use is "clean up a shell capture", and FIELD says that portably in one spelling:

* the FIRST LINE of a capture, with any mark or CR ahead of it dropped
LINE1 = FIELD(FIELD(FIELD(X, @AM, 1), CHAR(10), 1), CHAR(13), 1)

Verified identical on MVX, UniData 8.3, UniVerse 14.2 and jBASE 6.2.1.1.

It recurred. This page already carried the warning above when mv_package#54 was filed, and mvpkg still had the U2 order in six places — because the symptom never looked like CONVERT. There it turned MVPKGOS "PLATFORM" into udt:<LF>:<LF>:le, the newlines went into a registry query string, the HTTP call failed, and every install reported "not found in registry". A documented trap is not a fixed one: when you meet this, grep the whole tree for CONVERT( rather than fixing the site in front of you.

jBASE's EXECUTE runs OS commands directly

There is no shell-escape verb on jBASE — and none is needed, which is easy to get backwards. EXECUTE runs the command itself:

EXECUTE "echo hello" CAPTURING C              ;* C<1> = "hello"
EXECUTE "CREATE-ACCOUNT /tmp/x" CAPTURING C   ;* really creates the account

What does not work is the wrapper other platforms use. On UniData and UniVerse you reach the OS through SH -c '...'; on jBASE SH is not a command, so that form fails with SH: No such file or directory — and so do !cmd, SHELL cmd and DOS cmd. It is easy to read those four failures as "no shell access" and conclude the platform cannot do it at all. Drop the wrapper instead.

Two consequences worth planning for:

  • No shell means no shell syntax. No 2>&1, no pipes, no globbing, no quoting rules. CAPTURING takes stdout only, so anything the command reports on stderr goes to the terminal rather than into your variable.
  • A spawned MV binary makes its own session. If it calls the session factory — as any tool built on the jBASE API does — an install whose licence counts sessions pays for two while it runs. The same is true of shelling out to udt-git or uv-git on the other platforms. Fine for a one-off like clone; wrong for anything in a loop.

jBASE EXECUTE ... CAPTURING cannot see stderr, and has no exit status

It takes stdout alone, and there is no status to inspect afterwards. The workaround every other platform uses is unavailable too: jBASE's argument handling means /bin/sh -c "cmd 2>&1" arrives as a filename for sh to run.

So a command that failed and explained itself on stderr reaches BASIC as an empty variable — indistinguishable from a command that succeeded quietly. If what you are running can fail in a way you must react to, run it from C (popen with 2>&1) and hand BASIC both the text and the status.

Two silent C traps when writing a jBASE DEFC primitive

Both take the process down with exit 201 and no message at all — the CRT before the CALL prints, the one after does not:

  • -std=c11 is strict ISO C. strdup, popen, pclose and the <sys/wait.h> status macros are POSIX, so they are not declared, and an undeclared function is assumed to return int — which truncates a 64-bit pointer. free() on it kills the process. #define _POSIX_C_SOURCE 200809L before the includes.
  • Return values come back as the DEFC's result, not through an extra VAR. Storing an integer into a VAR the caller passed fails the same silent way.

The compiler warns about the first (implicit declaration of function) if you are reading build output — which is a good reason not to send it to /dev/null.

KEYIN() and jsh's command loop

KEYIN() reads a pipe perfectly well when it owns one, so a full-screen program can be driven headlessly. It cannot be driven through jsh: jsh reads its own input line-buffered, so piping a sentence followed by keystrokes leaves KEYIN() with Error getting input from STDIN , errno = 0 and jsh then runs the keystrokes as its next command. Invoke the cataloged program directly and give it stdin.

jBASE's environment shadows sort

jbase_env.sh puts jBASE's bin ahead of everything, and it ships its own sort. Any shell script that sources the environment and then pipes through sort gets jBASE's, which answers:

No file name could be found for your query

It is the only coreutil shadowed on 6.2.1.1 (grep, uniq, head, tail, wc, find, sed and awk all still resolve to /usr/bin), which is what makes it easy to miss — everything else in the pipeline behaves. The failure looks like a quoting or working-directory problem rather than the wrong binary, and it produced several confidently wrong diagnoses here before it was spotted.

Use /usr/bin/sort in any script that sources jbase_env.sh.

GETENV does not exist on jBASE, and the error names your variable

GETENV("HOME") compiles on jBASE and then fails at RUN time with

Error in Conversion Code "HOME"
Unknown conversion code.

which reads as a bad OCONV mask rather than a missing function -- the argument you passed is quoted back at you as though it were a conversion code. It is already on the not-everywhere list below; the reason it earns a section is that the error points at the wrong thing, so it survives a casual read of the output.

Found by running mvpkg on jBASE 6.2.1.1: 21 env reads across 9 programs, and only 3 of them were in the OS seam.

jBASE accounts have an MD, not a VOC

OPEN "VOC" fails on every jBASE account -- there is no such file. The account dictionary is MD (240 records in a freshly created account).

Do NOT guard each open. The name is one fact, and a codebase that opens the master dictionary in fourteen places would carry fourteen copies of it. Declare it once in the generated PLATFORM.H:

EQUATE MVMASTER TO "MD"        <- jBASE
EQUATE MVMASTER TO "VOC"       <- MVX, UniData, UniVerse

and write OPEN MVMASTER TO F. Two details matter:

  • EQUATE, not a valued $DEFINE. UniData has no value-substitution form of $DEFINE, so $DEFINE MVMASTER "VOC" will not substitute there. EQUATE to a string literal is ordinary MV BASIC and compiles on all four.
  • Bake the value in; do not $IFDEF inside PLATFORM.H. A nested guard is precisely the construct that compiles on UniData and fails on UniVerse, and a generated per-platform file has no reason to carry a decision it already made. The same applies to an $IFDEF inside an $ELSE anywhere: use one flat top-level guard per platform, even though it repeats a little. mvpkg's test suite now fails the build on a nested one, having found two that had been working on three systems and broken on the fourth.

Have the error messages name MVMASTER too, so a failure says which file it could not open.

CREATE.FILE DIR <name> makes a file called DIR

UniData reads DIR as the file TYPE. jBASE reads it as the file NAME, and leaves DIR and DIR]D in the account while the file you asked for never appears. Nothing errors, so the failure surfaces later as a missing file.

One package's DEFC symbols can break every cataloged program you have

jBASE catalogs per user, not per account: CATALOG BP FOO from an account leaves that account's own bin/ and lib/ empty and writes $HOME/bin/FOO, with subroutines going into a single shared $HOME/lib/lib0.so.

That sharing has a sharp edge. A program whose DEFC names a symbol the loader cannot find does not fail on its own — it makes the shared library unloadable, so every other cataloged program for that user stops working too, with a message naming neither of them:

jBASE: /home/rocky/lib/lib0.so.314: undefined symbol: JBCURLGETFILE
 ** Error [ SUBROUTINE_CALL_FAIL ] **
Unable to perform CALL to subroutine CMD.INIT , Line 32 , Source MVPKG

That is MVPKG failing because an unrelated HTTP package was cataloged earlier in a different account. The symbol named is not the one being called, and the program named has nothing to do with the package at fault.

Two consequences worth designing around:

  • A package that adds DEFC entry points must ship its library and get it loaded — see the LD_PRELOAD section above. Cataloging the BASIC half alone leaves a landmine for everything else that user runs.

  • DELETE-CATALOG is the cure, and it rebuilds the shared library as it goes:

    Object HTTPGET decataloged successfully
    Library /home/rocky/lib/lib0.so.315 rebuild okay
    

    Note the version number moving: each catalog change writes a new lib0.so.N, which is also why a stale LD_PRELOAD or a long-lived session can be pinned to an older one.

Measured on jBASE 6.2.1.1 while installing mvpkg (mv_package#60): the install was correct and MVPKG list still failed, because an earlier experiment had left an unresolved symbol in the user's lib0.so.

Cataloging to a per-account $JBCDEV_LIB breaks CALL

JBCDEV_LIB and JBCDEV_BIN are UNSET by default, and the default is what you want: CATALOG writes $HOME/lib/lib0.so.<n> and the runtime resolves against it. Point them at an account-local lib/ and everything still compiles and catalogs -- reporting success -- but every call fails at run time with

** Error [ SUBROUTINE_CALL_FAIL ] **
Unable to perform CALL to subroutine CMD.INIT

because nothing tells the loader about the new location.

Correction (measured on 6.2.1.1): the search path IS configurable — with JBCOBJECTLIST. Setting JBCDEV_LIB alone moves the output and leaves the loader looking in the old place, which is the failure above. Set both and it works, for a cataloged SUBROUTINE as well as a verb:

# build time — where the catalog is written
JBCDEV_LIB=/opt/pkg/cmd/lib JBCDEV_BIN=/opt/pkg/cmd/lib  CATALOG BP CMD.INIT
    Library /opt/pkg/cmd/lib/lib0.so.1 rebuild okay

# run time — where the loader looks
JBCOBJECTLIST=/opt/pkg/cmd/lib  ZCALLER
    got: subroutine reached          # without JBCOBJECTLIST: SUBROUTINE_CALL_FAIL

That matters for more than tidiness. The default puts every package's catalog in ONE $HOME/lib/lib0.so, where a single unresolvable DEFC symbol makes the whole library unloadable and breaks every other cataloged program for that user (see the section above). Giving each package its own JBCDEV_LIB directory isolates that damage — the same shape MVX already has, where a package builds LIB/libcmd.so of its own rather than sharing one.

The library inside the directory is still named lib0.so.<n>: what you get to choose is the DIRECTORY, not the filename. Isolation is per directory, which is enough.

Where to keep the search path: jBASE's own config, not a shell profile. $JBCGLOBALDIR/config/jbase_config.json carries an environment array that every session reads, with $VAR expansion — the same array PATH and LD_LIBRARY_PATH are set in, and jBASE documents this variable there itself:

# JBCOBJECTLIST defines the directories to find user shared object libraries
# where user subroutines are located.
#   {"name": "JBCOBJECTLIST", "default": "$HOME/lib"},

A profile in the account directory is not an option: jBASE reads nothing from an account directory. A .profile dropped in one is ignored, and JBCOBJECTLIST is empty by default — so anything kept there needs a human to source it every session. On a stock install the config is group jbase and group-writable, so an operator can maintain it without sudo.

Order matters, and $HOME/lib belongs LAST. jBASE searches the list in order, so whatever sits earliest wins a name. $HOME/lib is where a tool catalogs its own bootstrap copies of names that real packages later provide — put it first and those cut-down copies shadow the real package for every program on the system, permanently, while everything still appears to work.

Setting JBCDEV_LIB from BASIC: PUTENV("JBCDEV_LIB=" : DIR) works and the EXECUTEd CATALOG inherits it, so a program can direct a catalog without shelling out.

jBASE takes a path where U2 needs a file pointer

To compile from a tree outside the account — a package store, a staged release — UniData and UniVerse need a temporary file pointer in the master file. jBASE does not: BASIC and CATALOG both take a path directly, and both take a wildcard over it:

BASIC   /home/rocky/mvpkg/mvx-lang_getopt/BP *      -> 11 compiled
CATALOG /home/rocky/mvpkg/mvx-lang_getopt/BP *      -> 11 cataloged

And the pointer approach does not merely differ, it does not work: an MD record of DIR/path/D_BP — the UniData spelling — fails to open, and so does a Q pointer to the path.

Nor will SELECT enumerate such a directory. Opening the path succeeds and then selects nothing — 0 items against 11 files — and jBASE's own CREATE-FILE ... TYPE=UD behaves the same. So list the directory with the shell when you need the names, and hand the path to the verbs when you need the work done.

CREATE-ACCOUNT takes one argument

CREATE-ACCOUNT <path> -- the last element of the path becomes the account name. It refuses a directory that is not empty unless given -f, and creates bin/, lib/ and the MD. A bare directory with a BP in it is not an account: COUNT MD answers "No file name could be found for your query".

$IFDEF has no OR — and two systems pretend it does

There is no portable way to write "either of these symbols". Neither spelling works, and the two failure modes are not equally kind:

$IFDEF AA || BB $IFDEF AA OR BB
UniData syntax error syntax error
jBASE compiles, reads AA, ignores the rest same
MVX compiles, reads AA, ignores the rest same
UniVerse silently false if the first symbol is undefined; compile error if it is defined same

UniData is the honest one: it refuses. jBASE and MVX take the first symbol and silently discard everything after it, which means the construct appears to work whenever the defined symbol happens to be written first.

UniVerse is the worst of the four, because it does BOTH depending on the input: with the first symbol undefined it skips the block quietly, and with the first symbol defined it enters the block and then chokes on the leftover tokens. The same line therefore compiles on one machine and fails on another according to which symbols happen to be set.

$DEFINE BB
$IFDEF BB || AA     -> TRUE   (looks like OR works)
$IFDEF AA || BB     -> FALSE  (same expression, reordered)

Both were tested by DEFINING ONLY THE SECOND SYMBOL and printing which branch ran -- compiling proves nothing here, because the broken cases compile.

Write nested guards, or $IFDEF the negative case, or put the fact in the generated PLATFORM.H and test one symbol.

MVX will not be "fixed" to support this, deliberately. MVX is our own compiler, so adding || there is possible -- and would be a trap: source using it would compile correctly on MVX and be silently wrong on jBASE, or refuse to compile at all on UniData. One symbol per $IFDEF is the portable subset, so that is the subset MVX targets too. Being as capable as the least capable system is the point.

On jBASE, a $DEFINE inside a FALSE $IFDEF still takes effect

This is the same trap one level deeper, and it is the more expensive one:

$IFDEF MVX
$DEFINE GETENV ENV
$ENDIF
   V = GETENV("HOME")

On jBASE, where MVX is not defined, that still rewrites GETENV to ENV. The compiler mentions it only as a warning -- "Variable ENV is never assigned" -- so the program compiles, catalogs, and fails at RUN time with

Error in Conversion Code "HOME"
Unknown conversion code.

which names your variable as though it were a bad OCONV mask. GETENV itself works perfectly well on jBASE; the guard around it was the bug.

The fix is not a better guard. Put the platform fact in the generated PLATFORM.H, where only the platform that needs it gets a copy and there is no $IFDEF to leak through. See MVMASTER above.

Reaching a shell from jBASE: write the command to a file

UniData's ! prefix hands the line to a shell. jBASE has no ! -- EXECUTE reaches the OS directly, argv-style. There is no shell, so |, >, && and $? are not special, and quotes arrive as literal characters instead of grouping anything. EXECUTE '!mkdir -p /x' fails as:

!mkdir: No such file or directory

sh -c "..." does not fix it, and it is the first thing everyone tries: the quotes meant to hold the command together are passed through as text, so the command arrives split across argv.

What works is to write the command to a script and run sh on the script:

   SHSCRIPT = SHCMD
   SHSCRIPT<-1> = "echo $? > " : SHR      ;* exit status, which CAPTURING never gives
   OSWRITE SHSCRIPT ON SHF
   EXECUTE "sh " : SHF CAPTURING SHOUT

The file is the quoting boundary -- nothing re-parses the command text between there and sh -- so pipelines, redirection, quotes and $? all behave normally. The EXECUTE line carries a command and a path and nothing else.

Two traps inside that:

  • Do not redirect on the EXECUTE line. EXECUTE "sh f > out 2>&1" hands jBASE > and 2>&1 as literal argv words -- the very problem being solved. Redirect inside the script, or take stdout through CAPTURING.
  • OSWRITE ... ON f ELSE is a syntax error on jBASE, as on UniData. No ELSE clause.

Getting the status back needs FIELD(rc, CHAR(10), 1), not TRIM(rc): echo leaves a newline, TRIM strips only spaces, and NUM() then rejects the value so every status reads as "unknown".

Provisioning a UniVerse account: answer TWO prompts, not one

A directory is not an account until uv has set it up, and running uv there asks two questions, not one. Piping only Y leaves you with no VOC and the misleading impression that setup silently failed:

This directory is not set up for uniVerse.
Would you like to set it up (Y/N)? Y
 0. Ideal UniVerse compatibility
 1. IN2 compatibility          ...
Which way do you wish to configure your VOC ?

So:

mkdir -p "$T" && cd "$T"
printf 'Y\n0\nQUIT\n' | "$UVHOME/bin/uv"      # 0 = Ideal UniVerse
[ -e VOC ] || { echo "not provisioned"; exit 1; }   # always assert it worked

Let CREATE.FILE make its own directory. mkdir BP followed by CREATE.FILE BP 19 fails -- the directory already exists -- and you are left with a directory that is not a file, which then reports "Unable to open BP file" from the compiler rather than from the thing that actually went wrong. The same applies to UniData, where a pre-made MVPKG.INC leaves the VOC pointer uncreated and $INCLUDE then cannot find it.

Creating a UniVerse file from BASIC: stack the answers with DATA

CREATE.FILE asks its seven questions whatever arguments you give it, and there is no argument form that answers them. Every spelling that looks like it should either mis-parses or leaves the DATA part unspecified:

>CREATE.FILE MVPKG.INC 19
Please enter the following information for the DATA file:
Modulo           =                     <- the 19 was read as the DICT modulo

>CREATE.FILE DIR MVPKG.INC             <- the UniData spelling
Improper modulo number entered.
Please enter the following information for the DICTionary file:
Modulo           =

From TCL you can pipe the answers. From BASIC you cannot, and a question asked inside EXECUTE is not an error — it is a hang. Put the answers on the input stack instead:

      DATA 1, 2, 3, 1, 2, 19, ""       ;* dict 1/2/3, data 1/2/19, empty description
      EXECUTE "CREATE.FILE MVPKG.INC" CAPTURING JUNK
      CLEARDATA

Three things about that line are load-bearing:

  • The empty seventh answer is the file description. Leave it out and the question is asked; answer it with text and VOC attribute 1 becomes F <description> (see below), which breaks anything matching on "F".
  • Types 18 (hashed) and 19 (directory), never 30. UniVerse rejects 30, re-prompts, and the re-prompt eats the rest of the stack — so the file is never created and the leftovers are inherited by whatever EXECUTEs next. That second failure lands a long way from its cause.
  • CLEARDATA afterwards, for the same reason: a create that consumes fewer answers than it was handed leaves the rest on the stack.

Verified on UniVerse 14.2.1. It is idempotent — creating a file that already exists leaves the file, and its records, alone.

A Q-pointer names an ACCOUNT, and the account has to be registered

Reaching a file in another account, a Q-pointer's second attribute is an account NAME, resolved through UD.ACCOUNT — so it only works for accounts that registry knows:

MVPKG.STORE:                     :CT MVPKG.STORE installed
Q                                MVPKG.STORE is not a desired record in VOC file.
mvpkg                            :CT UD.ACCOUNT mvpkg
MVPKG.STORE                      UD.ACCOUNT does not exist in VOC file.

An account made by hand — newacct, or an installer that provisions its own — is not in there, and the pointer names something unresolvable. A file pointer carrying absolute paths needs no registry:

      WRITE ("F" : @FM : P : "/MVPKG.STORE" : @FM : P : "/D_MVPKG.STORE") ON VOC, "MVPKG.STORE"

Verified on UniData 8.3 and UniVerse 14.2.1. jBASE needs neither — it opens an absolute path as a file directly, which the other two refuse:

platform OPEN "/path/to/FILE" F pointer with paths
jBASE works —
UniData fails works
UniVerse fails works

A VOC file pointer says F on UniVerse and DIR on UniData

Same record, three attributes, different attribute 1 — and UniVerse does not accept the UniData spelling, so a hand-written pointer just fails to open:

platform attribute 1
UniVerse F
UniData, jBASE DIR
      WRITE ("DIR" : @FM : SDIR : @FM : "D_BP") ON VOC, "MVPKG.SRC"
      OPEN "MVPKG.SRC" TO F ELSE PRINT "cannot open"   ;* <- taken, on UniVerse

Read one of UniVerse's own back (CT VOC BP) and it says F. This matters whenever code writes a temporary pointer to reach a directory outside the account — a package store, a staged tree — because there CREATE.FILE is not an option and the pointer has to be built by hand.

Cataloging on UniVerse is per account, and *NAME is not

UniVerse has a global catalog, but its layout is not UniData's $UDTHOME/sys/CTLG/<letter>/<name> and code that addresses that tree finds nothing. The workable unit is the account: CATALOG <file> <prog> LOCAL FORCE registers the program in this account's VOC, which is what makes it both typeable as a verb and CALLable as a subroutine.

Two consequences that both fail confusingly:

  • The catalog pointer is type V, not C. UniData writes C; UniVerse's LOCAL catalog writes V, with the object path in attribute 2. A catalog-lookup helper reading only "C" answers "not cataloged" for every program on UniVerse — so an availability probe reports a working dependency as absent, and a package with a fallback bundles its own copy of something already installed.

  • DEFFUN ... CALLING "*NAME" asks for the GLOBAL catalog. The leading star is not decoration; with LOCAL cataloging it fails at run time with

    Program "MVPKG.SEARCH": Line 42, "*MVPKG.MAPFIELD" is not in the CATALOG space.
    

    even though MVPKG.MAPFIELD is cataloged and callable. Drop the star — CALLING "MVPKG.MAPFIELD" resolves through the account's VOC. UniData needs the star, so this is a real per-platform difference and not a tidy-up:

    $IFDEF UDT
       DEFFUN MAPFIELD(A, B, C, D, E) CALLING "*MVPKG.MAPFIELD"
    $ENDIF
    $IFDEF UV
       DEFFUN MAPFIELD(A, B, C, D, E) CALLING "MVPKG.MAPFIELD"
    $ENDIF

UniVerse has neither OSREAD/OSWRITE nor GETENV

Both are on the not-everywhere table below, but it is worth seeing what they look like, because neither error names the missing feature:

000027   OSREAD DEF FROM UDT : "/bin/work/cfuncdef" ELSE DEF = ""
              ^   Variable Name (UNDEFINED) unexpected, Was expecting: Assignment Operator

         Array 'GETENV' never dimensioned.

UniVerse parses OSREAD as a variable being assigned, and GETENV(...) as a subscripted array. Use OPENSEQ/READSEQ/WRITESEQ for the first; for the second there is no built-in at all, so it has to come from the shell (printenv NAME).

Both are worth routing through a single subroutine each rather than guarding at every call site -- mvpkg has 42 OSREAD/OSWRITE and 20 GETENV uses, and guarding those in place would mean 62 copies of the same two facts.

And when you do route them through one subroutine, decide what it returns: OSREAD does not hand back the same thing on every port. See "OSREAD returns the file's bytes on two systems and a dynamic array on two" in section 3 -- that is the silent-difference half of this same statement.

RETURN(value) needs its parentheses on UniVerse

In a FUNCTION, UniVerse requires the parenthesised form:

FUNCTION F(A)
   RETURN(A : "-ok")      <- compiles
   RETURN A : "-ok"       <- syntax error on UniVerse, fine everywhere else

The error is reported against the RETURN line and mentions neither functions nor parentheses:

Variable Name (LOCAL) unexpected, Was expecting: ';', End of Line

RETURN(x) is accepted by MVX, UniData and jBASE too, so it is the portable spelling -- always write the parentheses.

@USER.TYPE is UniData-only — and on jBASE it silently inverts your guard

UniVerse has no such variable: it is a compile error there, which is at least loud. jBASE is the dangerous one. It compiles, and returns an EMPTY STRING:

X = @USER.TYPE
PRINT "len=":LEN(X):" isnum=":NUM(X):" ne0=":(X # 0)
len=0 isnum=1 ne0=1          <- jBASE 6.2.1.1

"" # 0 is true (measured the same on jBASE, UniData 8.3 and MVX), so the standard guard

IF @USER.TYPE # 0 THEN RETURN      ;* "not a terminal, stay quiet"

returns ALWAYS on jBASE — the routine never runs, on any terminal, and nothing reports it. That is the exact opposite of the advice below, arrived at by accident.

Guard the check, not the program, and decide what "cannot ask" should mean: for an advisory message, assume interactive, because staying silent in a real terminal is worse than printing in a phantom.

What @USER.TYPE answers where it works (UniData 8.3, and MVX): 0 on a real terminal, 1 in a phantom, 2 in a piped session. SYSTEM(2)/SYSTEM(3) are no substitute — they returned the 80x23 defaults in all three contexts.

Note for test suites: a piped session answers 2, so a guard of this shape returns immediately in any scripted test. A routine behind one has never been exercised by a piped suite.

@LEVEL exists everywhere — and OpenQM counts from 1, not 0

@LEVEL is how many programs are above this one. It compiles on every system measured, which makes it look like the portable answer where @USER.TYPE is not. It is not, and the way it fails is silent.

@LEVEL from the prompt through an EXECUTE
UniData 8.3 0 1
UniVerse 14.2.1 0 1
jBASE 6.2.1.1 0 1
ScarletDME (OpenQM) 2.6-6 1 2
MVX 0 1

So the idiom everyone writes

IF @LEVEL THEN RETURN      ;* something is above me -- ask nobody

is true at the top level on OpenQM, and the routine returns always — the same shape of silent failure as @USER.TYPE on jBASE, from the opposite direction. Nothing reports it.

Portable code must not test @LEVEL for truth. Capture the base at entry and compare against it, or test @LEVEL > BASE where BASE is established once:

COMMON /LVL/ BASE.LEVEL
IF BASE.LEVEL = "" THEN BASE.LEVEL = @LEVEL      ;* whatever the top is here
IF @LEVEL > BASE.LEVEL THEN RETURN

It is also not a replacement for @USER.TYPE, whatever the base: in a phantom it is still the top value (measured on UniData), so it cannot tell you whether a terminal exists. The two are orthogonal, and a routine that prompts wants both — and neither one ports without care.

UniVerse checks COMMON block sizes across a CALL; jBASE does not

A caller declaring

COMMON /CMDPKG/ CNAME, CDESC, CSUBS, CDESCS, CHANDLERS

against a subroutine declaring the same block with one more variable runs fine on jBASE and fails on UniVerse at RUN time:

Program "X": Line 1, COMMON size mismatch in subroutine "CMD.INIT".
Program "X": Line 1, Unable to load subroutine.

Which is the better behaviour -- but it means a mismatched declaration can sit in working jBASE code for a long time before UniVerse finds it. Keep the block in one include, or copy it exactly.

Building an SH -c command: build the quotes, do not write them

The UniVerse shell escape is SH -c, so the whole command becomes ONE argument to sh and has to survive two parsers. Spelling that inline mixes ' and " until neither balances, and the failure is silent:

   EXECUTE "SH -c 'curl -sL -o " : TMP : ' "' : URL : "'" CAPTURING C

emits SH -c 'curl -sL -o /tmp/f "https://...' -- the double quote never closes, curl sees no URL, writes nothing, and the caller gets "" with no error raised anywhere. It is indistinguishable from an empty response.

Build the quotes from character codes so the nesting is explicit:

   UDQ = CHAR(34) ; USQ = CHAR(39)
   EXECUTE "SH -c " : UDQ : "curl -sL -o " : TMP : " " : USQ : URL : USQ : UDQ CAPTURING C

Better still, where the command is complex, write it to a script and run sh on the file -- see the jBASE section above. The same reasoning applies: a command that contains quotes cannot survive being wrapped in more quotes.

Others found the same way

name where what happens
GT ECL (UniData) reserved word; a program named BP/GT fails to compile with a misleading caret
EQ MVX relational operator — EQ = INDEX(...) is "expected statement"
SETTING UniData LOCATE ... SETTING; fails as a label, not only a variable

2. Statements that do not exist everywhere

These must be excluded by the preprocessor, not a runtime branch — the text still has to compile.

Guard on the positive platform ($IFDEF UDT), never on $IFNDEF MVX, which is true on every other platform too.

statement exists on trap elsewhere
CALLC UniData UniVerse parses R = CALLC F(x) as an array reference
OSREAD / OSWRITE UniData UniVerse parses them as variables
GETENV UniData UniVerse has none — capture shell output instead
KEYIN() MVX, UniVerse UniData has none, and a bare KEYIN compiles as an ordinary never-assigned variable
CONVERT x TO y IN z UniData, UniVerse not on MVX — the 3-argument function CONVERT(from,to,expr) works everywhere
SELECT fvar TO 9 UniData UniVerse rejects the numbered list: "option 'S' requires variable name". A select-list variable works on both
MAT parameters UniVerse UniData rejects them — "redefined variable ARGV"; MAT ARGV(8) in a signature is a syntax error. Only plain scalars are portable. jBASE accepts them but the dimension is real — it range-checks at compile time, so a signature's MAT A(1) rejects A(2) in the body
DEFFUN f() CALLING "*NAME" UniData, UniVerse jBASE has no CALLING clause — syntax error near "CALLING". jBASE resolves a DEFFUN by name against the catalog, so where the name is already cataloged the aliasing is not needed; guard the clause and declare the plain DEFFUN
LOCATE ... SETTING (Format 2) flavour-dependent use parenthesised Format 1 only; Format 2 searches a different level on UniData than UniVerse
OSWRITE ... ELSE — no ELSE clause on UniData: "Misuse of reserved word 'ELSE'", reported at a line well past the real one. OSREAD ... ELSE is fine
TRANSACTION START/COMMIT/ABORT MVX, UniData, UniVerse jBASE spells it TRANSTART / TRANSEND / TRANSABORT and rejects the U2 form with a bare syntax error under START
@TRANSACTION MVX, UniData, UniVerse jBASE has none — "Unknown @ system constant @TRANSACTION specified". jBASE asks the same question with TRANSQUERY()

X<-1> = "" does not append on UniVerse

The append form adds an element on UniData and jBASE. On UniVerse an EMPTY value is a no-op — the array does not grow — so two arrays maintained in parallel fall out of step the moment one of them takes an empty:

      A<-1> = "one"   ; B<-1> = "1.0"
      A<-1> = "two"   ; B<-1> = ""
      A<-1> = "three" ; B<-1> = "3.0"
platform DCOUNT(A) DCOUNT(B) B<2>
UniVerse 3 2 "3.0"
UniData 3 3 ""
jBASE 3 3 ""

Nothing fails. Every read after the empty is simply off by one, and it stays that way in whatever the record is written to. It cost a package manager its version column: each package was paired with the NEXT one's version, so a freshly installed package reported as needing an upgrade.

Use an explicit index for parallel arrays, and the class disappears:

      N = N + 1 ; C<N> = "two" ; D<N> = ""      ;* both grow, always

The same applies to <-1> on values and subvalues. If you must use the append form, never let one of a parallel pair take an empty — push a sentinel instead.

3. Compiles everywhere, behaves differently

The dangerous section. Nothing fails; you get a wrong answer.

FMT(v, "R%n") — zero-fill

Honoured on UniVerse, ignored on UniData, which returns the mask itself: FMT(5,"R%4") gives "%4". # fills with spaces on both. There is no portable zero-filled fixed-width spelling — pad by hand:

PADR = V:"" ; IF LEN(PADR) < W THEN PADR = STR("0", W-LEN(PADR)) : PADR

OCONV(str, "MX") — hex encoding

Hex-encodes a string on UniVerse; a no-op on UniData (there MX is numeric-to-hex: OCONV(255,"MX") gives "FF").

Use "MX0C" — string-to-hex on both, byte-identical, CHAR(254) included, and ICONV(...,"MX0C") round-trips with length preserved.

Both of these hit a length-framed wire protocol at once and produced no error anywhere: a framed wire has no resynchronisation point, so a short frame just makes the far end wait forever.

@TRANSACTION is 1 on UniData and an ever-increasing NUMBER on UniVerse

Inside one transaction, UniData 8.3 answers 1 (outside=0, inside=1). UniVerse answers whatever the next transaction number happens to be — measured on 14.2.1, the same one-START program answered 3, then 4, then 7, 8, 9 on successive runs:

PRINT "outside=":@TRANSACTION
TRANSACTION START ELSE PRINT "start failed"
PRINT "inside=":@TRANSACTION
TRANSACTION ABORT
outside=0
inside=7          <- 8 on the next run, 9 on the one after

Nesting is counted on top of that number. Three successive STARTs on UniVerse gave n, n+1, n+2, and each ABORT unwound one level, the last returning to 0:

outside=0     after start1=4   after start2=5   after start3=6
              after abort1=5   after abort2=4   after abort3=0

So IF @TRANSACTION = 1 compiles everywhere and silently never fires on UniVerse — the worst possible shape, because the branch simply never runs and no error is reported. Only truthiness ports:

IF @TRANSACTION THEN ...          * portable
IF @TRANSACTION = 1 THEN ...      * UniData only

Nesting differs too, and it is the same probe. UniVerse nests; UniData refuses. Three STARTs on UniVerse all succeeded (above). On UniData 8.3 the second one takes the ELSE and the depth does not move:

started
inside=1
nesting refused
after 2nd start=1

So a subroutine that opens its own transaction without checking gets a nested one on UniVerse and a failed START on UniData. Have it check first, and only commit what it started — this compiles and behaves identically on both:

MINE = 0
IF NOT(@TRANSACTION) THEN
   TRANSACTION START ELSE STOP
   MINE = 1
END
* ... do the work ...
IF MINE THEN
   TRANSACTION COMMIT ELSE PRINT "commit failed"
END

The block IF ... END is not optional. The one-line spelling IF MINE THEN TRANSACTION COMMIT ELSE PRINT "..." will not compile on UniVerse: the ELSE binds to the IF, which leaves TRANSACTION COMMIT without the clause UniVerse requires.

Called once outside a transaction and once inside one, that subroutine gives:

UniVerse:  in sub: depth=10 mine=1      UniData:  in sub: depth=1 mine=1
           in sub: depth=11 mine=0                in sub: depth=1 mine=0

Same behaviour, different numbers — which is the whole point of the entry.

And UniVerse requires THEN/ELSE on TRANSACTION START; a bare one will not compile:

000007          TRANSACTION START
                               ^
End of Line unexpected, Was expecting: "ELSE", "ON", "THEN", "THENEOL", "ELSEEOL"

TRANSACTION ABORT, conversely, rejects a clause:

000002          TRANSACTION ABORT ELSE PRINT "aborted badly"
                                ^
"ELSE" unexpected, Was expecting: ';', End of Line

Which is coherent — starting and committing can fail in ways a program should branch on, discarding cannot — but it means the three statements do not take the same shape, and a guard written for one will not compile for another.

An EXECUTE'd program does NOT join the caller's transaction

True on both jBASE and UniData, which is what makes it dangerous — it is not a difference to guard, it is a shared behaviour nobody expects. The child runs outside the transaction and sees a stale view of anything the parent wrote inside it, with no error anywhere.

On jBASE 6.2.1.1, the parent holds a transaction and the child reports none:

parent TRANSQUERY after start=1
  child TRANSQUERY=0

On UniData, a parent that writes a record inside a transaction and then EXECUTEs a COUNT of that file gets 0 record(s) counted.

Keep anything that must see the transaction's own writes in-process — a subroutine via CALL, not a program via EXECUTE.

While measuring that, two related facts on jBASE: an EXECUTE'd program gets a fresh unnamed COMMON (reading it raises "Invalid or uninitialised variable -- ZERO USED"), while a named COMMON block IS shared with the caller. So COMMON /NAME/ crosses an EXECUTE and bare COMMON does not.

A program SURVIVES its own LOGTO, and the new account's LOGIN runs

EXECUTE "LOGTO <account>" compiles everywhere. What it does afterwards is the part that differs, and nothing reports the difference.

On UniData 8.3 and UniVerse 14.2.1 the calling program keeps running and stays in the new account:

PRINT "program: before the logto"
EXECUTE "LOGTO artdemo"
PRINT "program: after the logto"
EXECUTE "WHERE"
program: before the logto
*** LOGIN PARAGRAPH RAN IN ARTDEMO ***
program: after the logto
/home/rocky/artdemo

Both lines print — so this is not a way to end a program — and the WHERE afterwards reports the new account. TCL was still in artdemo when the program ended. UniVerse behaves identically.

The target account's VOC LOGIN runs on the way in. With a PA paragraph written to VOC as LOGIN:

1 PA
2 DISPLAY *** LOGIN PARAGRAPH RAN IN ARTDEMO ***

it ran in all three cases on both systems: a fresh login to the account, a LOGTO typed at TCL, and the EXECUTE "LOGTO ..." above. So a program that moves account triggers arbitrary code belonging to that account — worth knowing before a menu moves between companies.

A handle opened before the LOGTO still reads afterwards, on UniData and UniVerse — a handle there names a file, not a store. Measured against a file that exists only in the account being left, so it is the handle surviving and not a file of the same name in the new account:

opened LTONLY in demo
/home/rocky/artdemo
  old handle reads: written in demo
  and LTONLY does NOT exist in artdemo (control)
opened after the LOGTO control
UniData 8.3 demo/LTONLY reads written in demo not in artdemo
UniVerse 14.2.1 bench/LTONLY reads written in bench not in ltacct

Use a file unique to one account when checking this yourself: an earlier run of this probe opened a file both accounts had, and could not tell a surviving handle from a same-named file in the new account.

Where it bites when porting to MVX: MVX has no LOGIN hook at all, so account setup that lived in a LOGIN paragraph has to move into the program. The move itself works — EXECUTE "LOGTO ...", or the native LOGTO() intrinsic, which returns 1/0 with the reason in STATUS() — but MVX closes the old account's files and refuses the move while a transaction is open. Code that keeps handles in COMMON across a LOGTO — the Gentrack pattern — must re-open them on MVX; a stale handle there says so rather than reading whatever the old one pointed at. The difference is not stubbornness: an MVX handle names a place in a store reached through the account's bindings, not a path to a file, so one that survived would follow the session into the new account rather than keep pointing where it was opened.

The login proc has a different NAME on each system, and UniVerse prefers its own

Both systems run something when you enter an account — a fresh login, a LOGTO from TCL, and a LOGTO from inside a BASIC program. What it is called differs, and nothing reports the mismatch.

PA paragraphs written into VOC, each printing a distinct banner:

record UniData 8.3 UniVerse 14.2.1 jBASE 6.2.1.1 ScarletDME 2.6-6
LOGIN runs runs nothing runs
the account name (demo, bench, lgtest, qmtest) ignored runs — matching the account name exactly as the system knows it, so lowercase bench; BENCH never fired nothing ignored
the Unix user name (rocky / ROCKY) ignored — — —
LOGIN and the account name both present LOGIN runs the account name wins; LOGIN does not run — LOGIN runs

Paragraphs (PA) and PROCs (PQ) both run on ScarletDME, so a LOGIN there may be either, as on the U2 systems.

Use LOGIN. It is the only spelling that works on both U2 systems.

jBASE has none of this, because its login IS the Unix login. Entering an account runs nothing: a PA record written into MD is not recognised — invoking it by name falls through to the OS (LOGIN: No such file or directory, because jsh runs OS commands) — and that holds under the default emulation and under -e d3. jBASE MD verb records are opcode entries (001 t, 002 16), not paths or paragraphs.

An account there is a directory registered in $JBCDATADIR/SYSTEM:

lgx
001 D                    <- type: directory
002 /home/rocky/lgx      <- path
003 (empty) ...

There is no VOC. You log into Unix, your shell profile sets the jBASE environment and starts jsh in an account directory — so the .profile is the login proc, and it lives outside the account. Nothing an account carries can run on entry.

Its LOGTO is also a genuine re-authentication: it prompts for a password (logto.b), where the U2 LOGTO just moves.

Unverified: the jsh binary carries three login-shaped hooks — a sourced shell script $JBCRELEASEDIR/config/jshlogin, a JBCLOGINPROG environment variable, and a LoginProcAttr1 symbol. On 6.2.1.1 none of them fired when tried: creating config/jshlogin, setting JBCLOGINPROG to a cataloged program, running jsh as a login shell (argv[0] = -jsh), and putting a verb in SYSTEM attribute 3 all did nothing. They may need a condition not reproduced here, or be vestigial. Do not rely on them without testing.

The trap runs one way: on UniVerse an account that already has an account-named record keeps using it and silently ignores a LOGIN added beside it. If a LOGIN you installed appears to do nothing on UniVerse, look for a VOC record named after the account.

An account-named login does not survive a rename, which matters when a whole account is moved or checked out under a different name — the login simply stops existing. Normalise it to LOGIN when moving an account between systems.

SYSTEM(12) is milliseconds — except on UniVerse, where it is seconds

Not a precision difference — a unit difference, so elapsed-time arithmetic is wrong by a factor of 1000 with nothing reported. Measured at roughly the same moment of the day:

TIME() SYSTEM(12)
MVX 48383 48383240 (ms)
jBASE 6.2.1.1 12368 12368816 (ms)
UniData 8.3 12458 12458050 (ms)
UniVerse 14.2.1 12229.8222 12229.8222 (s, fractional)

On UniVerse SYSTEM(12) is simply TIME() again, fraction included — it is the only one of the four that is not milliseconds.

TIME() carrying a fraction on UniVerse also breaks the standard "wait for the clock to tick" calibration loop, because TIME() # START is true immediately:

START = TIME() ; LOOP UNTIL TIME() # START DO REPEAT             * never waits on UV
START = INT(TIME()) ; LOOP UNTIL INT(TIME()) # START DO REPEAT   * portable

Left unguarded it does not fail — it reports. A benchmark calibrated this way on UniVerse claimed a run of 203,804 seconds.

OSREAD returns the file's bytes on two systems and a dynamic array on two

Reading a whole OS file gives you two different things depending on the port, and both compile and both look right:

how a file is read a two-line file AAA\nBBB\n
MVX OSREAD() function raw bytes — DCOUNT(nl)=3 DCOUNT(@AM)=1
UniData OSREAD ... FROM raw bytes — DCOUNT(nl)=3 DCOUNT(@AM)=1
jBASE OSREAD ... FROM newline → @AM — DCOUNT(nl)=1 DCOUNT(@AM)=2
UniVerse no OSREAD; READSEQ line by line whatever you join the lines with

So the same source line

OPATH = TRIM(FIELD(OINFO, CHAR(10), 2))

returns the second line on MVX and UniData, and the empty string on jBASE and UniVerse. No error, on any of them.

jBASE also drops the trailing newline, so a file ending in one reads back a byte shorter there than on UniData — which means DCOUNT(x, CHAR(10)) differs by one between ports even after you have normalised the separator. Parse with FIELD, never by counting to the end.

In mv_package#118 this made MVPKG.FILE("READ", ...) mean three things at once: its own header documented @AM, every one of its callers parsed CHAR(10), and only two of the four ports produced @AM. The store's operator marker parsed to an empty owner path, a VOC file pointer was written to /MVPKG.STORE instead of /home/rocky/uv98d/mvpkg/MVPKG.STORE, and MVPKG list reported

mvpkg: cannot open the package store MVPKG.STORE

on a fresh install that reported success at every step. The parallel STOREWRITE failed the same way and silently, so installs never reached the inventory either.

Normalise in the seam, and say which form it hands back. A seam whose documented contract nothing implements is worse than no seam: it makes the callers look wrong. If you convert on jBASE, use the CONVERT statement — the CONVERT() function takes its arguments in the opposite order there (see §3 above) and returns rubbish rather than failing.

UniData's BASIC verb SEGFAULTS at 26 items; UniVerse takes any number

BASIC <file> <item> <item> ... takes a list of items to compile. On UniData 8.3 a list of 26 or more crashes the session with SIGSEGV — after compiling every one of them successfully, so the output reads like a clean run right up to the moment it dies:

item id =GIT.AGENT
Compiling Unibasic: .../BP/GIT.AGENT in mode 'u'.
compilation finished
Segmentation fault

It is the count, not the length. Measured with synthetic programs so the name length is controlled:

items name length list bytes result
26 4 130 SIGSEGV
26 21 572 SIGSEGV
25 21 550 clean

130 bytes crashes where 550 bytes passes, so there is no command-line limit to work around by shortening names — the list has to be shorter than 26. Nor is it one bad program: two disjoint 26-item sets crash identically and both 25-item sets pass. UniVerse has no such limit and compiled the same 59 items in one command.

Compile in batches (mv_package uses 20, leaving room for the ceiling to be lower on another release). In mv_package#125 this made a 59-program package uninstallable on any UniData machine that did not already have its objects built.

Two things make this hard to see, and both are worth knowing on their own:

  • A crashed install leaves its objects behind, so the next attempt compiles nothing and succeeds. The bug reads as intermittent, and a control run measures nothing unless the built package is deleted by hand first.
  • Each SIGSEGV strands a UniData session holding a licence slot. On a 2-user Trial Edition, two crashes lock the system out with Licensed # of users has been reached — which looks nothing like the original fault. Clear them with listuser + deleteuser -f <pid>, and note that listuser writes its session table to STDERR: a 2>/dev/null silently discards exactly the rows needed to clean up, and the cleanup then appears to run and do nothing.

VOC attribute 1 carries a description

UniVerse writes attribute 1 as "F " — with a trailing space — when the CREATE.FILE description is empty, and appends the description when there is one, so a described file reads "F Customer master".

Anything matching attribute 1 must take the first TOKEN. Matching the whole field made mv_voc_class("F ", 2) answer "not a file" for every file in the account, and every downstream rule silently stopped firing.

Where compiled objects live

platform object
UniVerse a separate file named for the source file: BP → BP.O
UniData inside the source file, as _<PROG> beside <PROG>
jBASE inside the source file too, but as $<PROG>
MVX CATALOG/, which is not a record file

The two prefixes bite the same way and in shell as well as BASIC: a loop over BP/* that does not skip them finds twice as many "programs" after the first compile and tries to build _MVPKGOS or $MVPKGOS. In sh, note that $* in a case pattern is the positional parameters — the literal needs escaping:

case "$f" in (\$*|.*) continue ;; esac

Anything walking an account's records needs all three rules, because a repository is shared between systems.

On UniVerse the .O directory must already exist. Compiling from a file that points outside the account — a package store, a staged tree — the compiler creates the pointer to the object file and then stops, because the OS directory behind it is not there:

Creating file "MVPKG.SRC.O" as a remote pointer to operating system file "/store/pkg/BP.O".
The remote operating system file "/store/pkg/BP.O" does not exist.
[ENOENT] No such file or directory

So mkdir -p <dir>.O before compiling. UniData needs nothing equivalent: its objects go inside the source file as _<PROG>.

And the pointer comes in a pair. Compiling through a file pointer you wrote yourself makes UniVerse create a SECOND VOC record for the object side:

Creating file "MVPKG.SRC.O" as a remote pointer to operating system file
  "/store/pkg/BP.O".

It survives, and nothing you wrote knows about it. Re-point the original at a different directory and the compiler still writes to the old one, because the .O record was never updated — so a loop that compiles several trees through one temporary pointer puts every object in the FIRST tree, and catalogs every program against it. Measured across five packages: only the first had any objects, and every catalog entry named it.

Delete <name>.O alongside <name> whenever you create or drop a temporary pointer.


4. Per-platform facts worth knowing before you start

jBASE

  • The master file is MD, not VOC — the first platform to disagree.
  • MD is the MD]D file: jstat MD reports File …/MD]D, and CT MD / CT DICT MD return the same records. The master file is dictionary-only, so no MD data file exists on disk — code that skips ]D names as dictionaries makes the master file invisible.
  • CREATE-ACCOUNT registers the name in /jbasedata/SYSTEM]D and refuses a name already there. DELETE-ACCOUNT -f <name> clears it — and deletes the directory it points at, so guard it.
  • CREATE-FILE writes no MD record. SET-FILE <acct> <file> <name> does, and takes the account name, not a path.
  • A fresh account's MD holds 242 stock records; the template is $JBCRELEASEDIR/src/MD]D.

UniVerse

  • Every BASIC source file needs a trailing newline or the compiler reports "End of File unexpected" at the last line.

  • CREATE.FILE asks seven questions whatever arguments you pass, and the seventh is the file description — leave it empty (see attribute 1, above). From BASIC, stack the answers with DATA (see above); there is no argument form that avoids the questions.

  • Cataloging is per account. CATALOG <file> <prog> LOCAL FORCE is the unit that works; the pointer it leaves is VOC type V, and CALLING "*NAME" will not find it.

  • A directory that is not a VOC-registered file is invisible to BASIC, which reports it by compiling nothing rather than by failing.

  • Sockets are TCP-only; FIFOs carry binary if length-framed, and READBLK must never over-request.

  • Every bare RETURN inside a FUNCTION warns, and you cannot silence it. A GOSUB section has to end in RETURN, and the compiler cannot tell that from a function return, so it says:

    000093          RETURN
                        ^
    WARNING: no RETURN value specified, null used.
    

    The runtime is correct — the GOSUB returns to its caller and the function still returns its own value. Measured, rather than assumed:

    FUNCTION ZGTEST(X)
       MARK = "start"
       GOSUB SUB1
       MARK := "|after-gosub"
       RETURN("done:" : MARK)
    SUB1:
       MARK := "|in-sub"
       RETURN
    
    result=[done:start|in-sub|after-gosub]
    

    And RETURN "" is not the way to quiet it — it is a compile error:

    WARNING: no RETURN value specified, null used.
            ^
    String Constant unexpected, Was expecting: ';', End of Line
    1 Errors detected, No Object Code Produced.
    

    So the bare RETURN is the only form that compiles, and the warning is unavoidable noise. UniData says nothing about the same source. Do not restructure working code to chase it: count the warnings a file already produces, and look only at a change in the count.

UniData

  • $INCLUDE resolution needs CREATE.FILE DIR BP.INC, not CREATE.FILE BP.INC 19.
  • CallC gives C no file API, so a C extension cannot read records: BASIC drives the I/O and C does the work.

5. Environment traps that make failures lie

These produce failures that point nowhere near their cause. Check them before believing a mass failure is a code regression.

A system-wide catalog makes a failed install look like a working one. UniData's catalog is $UDTHOME/sys/CTLG, shared by every account on the machine, and jBASE's is ~/lib, shared by every account of that user. So an install that compiles NOTHING still leaves a runnable account, resolving against whatever an earlier install left there — for months, in one case.

The check that missed it asked whether the object exists:

ls "$UDTHOME"/sys/CTLG/*/MVPKGOS >/dev/null 2>&1 || die "catalog failed"

It cannot fail on a machine that has ever installed the package. Ask instead whether this run produced it:

MARK=$(mktemp)                  # before the catalog step
...
find "$UDTHOME"/sys/CTLG/*/MVPKGOS -newer "$MARK" | head -1

The same applies to the compile: udt and jsh both report a failed compile on stdout and still exit 0, so the exit status is not evidence and the output is. It surfaced only when a newly added program had no stale entry to fall back on — which is the general shape of it: shared state hides a broken install until something genuinely new is asked for.

  • TERM must be set (export TERM=vt100) on UniData and UniVerse. A bare non-interactive shell has no TERM, verbs produce empty output, and a whole suite reads as broken. Nothing in the failure mentions TERM.
  • Licence seats are few. UniData TE licenses 2 concurrent sessions; UniVerse TE has 2 seats (uvlictool). A suite faster than seats are released transiently wants a third, and the symptom is never the same twice: an explicit "user limit has been reached", or empty output, or the next command reporting a cold session's login banner. Report seats before a run — an unreported licence makes every failure below it misleading.
  • A dead session can hold a slot. UniData leaves a phantom listuser entry with no process; clear it with deleteuser.
  • Stale artefacts answer for the new ones. A rebuilt package does not change what runs if the installed binary is what the test invokes; a shared library assembled from two fragments staging the same object silently keeps one of them. Both have shipped month-old code while the install reported success.

6. Preprocessor traps

$DEFINE takes no value on UniData

It is a flag for $IFDEF, not a macro with a value. All three of these fail to compile on UniData 8.3:

$DEFINE MVMASTER "VOC"     * syntax error ... missing quote "
$DEFINE MVMASTER 'VOC'     * syntax error ... missing quote '
$DEFINE MVMASTER VOC       * syntax error at the $INCLUDE line

The error is reported against the including line, not the $DEFINE, so it points at the wrong file:

main program: syntax error at or before
<line 1>       $INCLUDE TSTINC PLATB.H

jBASE and MVX both accept a valued define and substitute it — verified on 6.2.1.1 and on MVX, where $DEFINE MVMASTER "MD" then CRT MVMASTER prints MD. So this compiles on two platforms and fails on a third, which is the worst way to find out.

To give BASIC a per-platform value, generate it into the per-platform PLATFORM.H as an ordinary assignment instead:

      MVMASTER = "MD"        * written by build-jbase.sh
      MVMASTER = "VOC"       * written by the others

Every source $INCLUDEs PLATFORM.H immediately after its SUBROUTINE line, so an assignment there is legal and becomes the first executable statement.

  • $IFDEF inside a comment still counts. Both of these compile clean on UniData and fail on UniVerse.
  • $ELSE-nested guards are not portable — flatten them.

Keeping this page current

When a port turns up a difference, add it here with the evidence — the source that failed and the exact message — not a summary. The message is what the next person will search for.

Put it in the section that matches how it fails, since that is what decides how hard it is to find: refuses to compile, compiles and misbehaves, or compiles everywhere and differs silently.

Clone this wiki locally